@fitlab-ai/agent-infra 0.9.12 → 0.9.13

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.
Files changed (239) hide show
  1. package/README.md +11 -11
  2. package/README.zh-CN.md +11 -11
  3. package/dist/bin/internal-cli.js +24 -19
  4. package/dist/lib/agent-clients/next-steps.js +6 -1
  5. package/dist/lib/decide.js +3 -3
  6. package/dist/lib/internal/platform-checks.js +3 -3
  7. package/dist/lib/internal/platform-comment.js +9 -9
  8. package/dist/lib/internal/platform-context.js +2 -2
  9. package/dist/lib/internal/platform-issue.js +8 -9
  10. package/dist/lib/internal/platform-pr-review.js +10 -10
  11. package/dist/lib/internal/platform-pr.js +63 -24
  12. package/dist/lib/internal/platform-release-notes.js +3 -3
  13. package/dist/lib/internal/pr-review-grade.js +11 -9
  14. package/dist/lib/internal/release-workflow.js +4 -4
  15. package/dist/lib/internal/sandbox-control.js +30 -4
  16. package/dist/lib/internal/task-activity.js +3 -3
  17. package/dist/lib/internal/task-artifact.js +76 -24
  18. package/dist/lib/internal/task-create.js +2 -2
  19. package/dist/lib/internal/task-event.js +6 -3
  20. package/dist/lib/internal/task-finalization.js +2 -2
  21. package/dist/lib/internal/task-invalidation.js +60 -0
  22. package/dist/lib/internal/task-ledger.js +9 -6
  23. package/dist/lib/internal/task-lifecycle.js +2 -2
  24. package/dist/lib/internal/task-override.js +4 -4
  25. package/dist/lib/internal/task-review.js +3 -3
  26. package/dist/lib/internal/task-verify.js +6 -6
  27. package/dist/lib/internal/task-warning.js +3 -3
  28. package/dist/lib/platform/adapters.js +1 -3
  29. package/dist/lib/platform/change-request-git-evidence.js +27 -7
  30. package/dist/lib/platform/completion-backfill.js +6 -5
  31. package/dist/lib/platform/context.js +70 -130
  32. package/dist/lib/platform/github-client.js +1 -1
  33. package/dist/lib/platform/github-provider.js +903 -0
  34. package/dist/lib/platform/in-label-sync.js +143 -0
  35. package/dist/lib/platform/issue-comments.js +94 -41
  36. package/dist/lib/platform/issues.js +335 -189
  37. package/dist/lib/platform/metadata-labels.js +6 -2
  38. package/dist/lib/platform/none-provider.js +28 -0
  39. package/dist/lib/platform/pr-change-report.js +551 -0
  40. package/dist/lib/platform/pr-checks.js +119 -104
  41. package/dist/lib/platform/pr-review.js +88 -72
  42. package/dist/lib/platform/pr-summary.js +520 -50
  43. package/dist/lib/platform/provider-bridge.js +45 -0
  44. package/dist/lib/platform/provider-contract.d.ts +487 -0
  45. package/dist/lib/platform/provider-contract.js +95 -0
  46. package/dist/lib/platform/provider-loader.js +251 -0
  47. package/dist/lib/platform/provider-validation.js +504 -0
  48. package/dist/lib/platform/pull-request-metadata.js +8 -6
  49. package/dist/lib/platform/pull-requests.js +783 -537
  50. package/dist/lib/platform/release-notes.js +78 -55
  51. package/dist/lib/platform/releases.js +79 -50
  52. package/dist/lib/platform/resource-identity.d.ts +26 -0
  53. package/dist/lib/platform/resource-identity.js +92 -0
  54. package/dist/lib/platform/task-identities.js +31 -0
  55. package/dist/lib/platform/verification-required.js +11 -12
  56. package/dist/lib/platform/verification-sync.js +112 -238
  57. package/dist/lib/pr-review/evidence-grading.js +19 -6
  58. package/dist/lib/run/index.js +63 -23
  59. package/dist/lib/sandbox/control/client.js +96 -15
  60. package/dist/lib/sandbox/control/executor.js +3 -3
  61. package/dist/lib/sandbox/control/protocol.js +77 -0
  62. package/dist/lib/sandbox/control/server.js +398 -25
  63. package/dist/lib/sandbox/control/state.js +204 -1
  64. package/dist/lib/server/protocol.js +38 -1
  65. package/dist/lib/task/activity-log.js +8 -1
  66. package/dist/lib/task/artifact-lifecycle.js +33 -5
  67. package/dist/lib/task/capabilities.js +239 -0
  68. package/dist/lib/task/commands/status.js +32 -0
  69. package/dist/lib/task/commit-operation.js +12 -1
  70. package/dist/lib/task/control-authority.js +8 -5
  71. package/dist/lib/task/create-service.js +8 -8
  72. package/dist/lib/task/current-contract.js +5 -1
  73. package/dist/lib/task/decision-details.js +1 -1
  74. package/dist/lib/task/events.js +297 -17
  75. package/dist/lib/task/finalization.js +37 -13
  76. package/dist/lib/task/human-override.js +58 -36
  77. package/dist/lib/task/invalidation-command.js +86 -0
  78. package/dist/lib/task/invalidation.js +260 -0
  79. package/dist/lib/task/ledger-intents.js +37 -0
  80. package/dist/lib/task/local-artifact-finalization.js +374 -0
  81. package/dist/lib/task/orchestration.js +49 -93
  82. package/dist/lib/task/pr-delivery-fact.js +102 -101
  83. package/dist/lib/task/review-fingerprint.js +24 -2
  84. package/dist/lib/task/rework-intent.js +138 -0
  85. package/dist/lib/task/skill-scope.js +23 -0
  86. package/dist/lib/task/task-execution-lock.js +27 -17
  87. package/dist/lib/task/verification-engine.js +43 -16
  88. package/dist/lib/task/verification.js +3 -3
  89. package/dist/lib/task/workflow-warning-intents.js +1 -1
  90. package/dist/lib/task/write.js +11 -0
  91. package/dist/lib/version.js +11 -1
  92. package/dist/lifecycle-build-manifest.json +3 -3
  93. package/lib/agent-clients/next-steps.ts +6 -1
  94. package/lib/decide.ts +3 -3
  95. package/lib/init.ts +1 -1
  96. package/lib/internal/platform-checks.ts +3 -3
  97. package/lib/internal/platform-comment.ts +8 -8
  98. package/lib/internal/platform-context.ts +2 -2
  99. package/lib/internal/platform-issue.ts +7 -8
  100. package/lib/internal/platform-pr-review.ts +9 -9
  101. package/lib/internal/platform-pr.ts +47 -22
  102. package/lib/internal/platform-release-notes.ts +3 -3
  103. package/lib/internal/pr-review-grade.ts +10 -8
  104. package/lib/internal/release-workflow.ts +4 -4
  105. package/lib/internal/sandbox-control.ts +27 -3
  106. package/lib/internal/task-activity.ts +3 -3
  107. package/lib/internal/task-artifact.ts +65 -22
  108. package/lib/internal/task-create.ts +2 -2
  109. package/lib/internal/task-event.ts +6 -3
  110. package/lib/internal/task-finalization.ts +3 -3
  111. package/lib/internal/task-invalidation.ts +39 -0
  112. package/lib/internal/task-ledger.ts +11 -8
  113. package/lib/internal/task-lifecycle.ts +2 -2
  114. package/lib/internal/task-override.ts +6 -6
  115. package/lib/internal/task-review.ts +3 -3
  116. package/lib/internal/task-verify.ts +6 -6
  117. package/lib/internal/task-warning.ts +3 -3
  118. package/lib/platform/adapters.ts +3 -2
  119. package/lib/platform/change-request-git-evidence.ts +36 -9
  120. package/lib/platform/completion-backfill.ts +9 -8
  121. package/lib/platform/context.ts +97 -134
  122. package/lib/platform/github-client.ts +2 -2
  123. package/lib/platform/github-provider.ts +910 -0
  124. package/lib/platform/in-label-sync.ts +208 -0
  125. package/lib/platform/issue-comments.ts +97 -47
  126. package/lib/platform/issues.ts +344 -174
  127. package/lib/platform/metadata-labels.ts +5 -2
  128. package/lib/platform/none-provider.ts +35 -0
  129. package/lib/platform/pr-change-report.ts +645 -0
  130. package/lib/platform/pr-checks.ts +126 -106
  131. package/lib/platform/pr-review.ts +88 -85
  132. package/lib/platform/pr-summary.ts +560 -53
  133. package/lib/platform/provider-bridge.ts +82 -0
  134. package/lib/platform/provider-contract.ts +536 -0
  135. package/lib/platform/provider-loader.ts +307 -0
  136. package/lib/platform/provider-validation.ts +529 -0
  137. package/lib/platform/pull-request-metadata.ts +10 -7
  138. package/lib/platform/pull-requests.ts +826 -543
  139. package/lib/platform/release-notes.ts +76 -59
  140. package/lib/platform/releases.ts +77 -43
  141. package/lib/platform/resource-identity.ts +121 -0
  142. package/lib/platform/task-identities.ts +40 -0
  143. package/lib/platform/types.ts +4 -2
  144. package/lib/platform/verification-required.ts +11 -13
  145. package/lib/platform/verification-sync.ts +112 -292
  146. package/lib/pr-review/evidence-grading.ts +25 -8
  147. package/lib/run/index.ts +57 -22
  148. package/lib/sandbox/control/client.ts +105 -15
  149. package/lib/sandbox/control/executor.ts +4 -4
  150. package/lib/sandbox/control/protocol.ts +113 -0
  151. package/lib/sandbox/control/server.ts +431 -23
  152. package/lib/sandbox/control/state.ts +251 -1
  153. package/lib/server/protocol.ts +36 -1
  154. package/lib/task/activity-log.ts +8 -0
  155. package/lib/task/artifact-lifecycle.ts +32 -6
  156. package/lib/task/capabilities.ts +262 -0
  157. package/lib/task/commands/status.ts +38 -0
  158. package/lib/task/commit-operation.ts +12 -1
  159. package/lib/task/control-authority.ts +11 -8
  160. package/lib/task/create-service.ts +8 -8
  161. package/lib/task/current-contract.ts +6 -1
  162. package/lib/task/decision-details.ts +6 -2
  163. package/lib/task/events.ts +296 -16
  164. package/lib/task/finalization.ts +50 -14
  165. package/lib/task/human-override.ts +70 -46
  166. package/lib/task/invalidation-command.ts +86 -0
  167. package/lib/task/invalidation.ts +332 -0
  168. package/lib/task/ledger-intents.ts +35 -1
  169. package/lib/task/local-artifact-finalization.ts +563 -0
  170. package/lib/task/orchestration.ts +52 -85
  171. package/lib/task/pr-delivery-fact.ts +131 -148
  172. package/lib/task/review-fingerprint.ts +23 -2
  173. package/lib/task/rework-intent.ts +165 -0
  174. package/lib/task/skill-scope.ts +24 -0
  175. package/lib/task/task-execution-lock.ts +23 -14
  176. package/lib/task/verification-engine.ts +52 -17
  177. package/lib/task/verification.ts +4 -4
  178. package/lib/task/workflow-warning-intents.ts +1 -1
  179. package/lib/task/write.ts +19 -0
  180. package/lib/update.ts +1 -1
  181. package/lib/version.ts +15 -1
  182. package/package.json +7 -1
  183. package/templates/.agents/rules/README.en.md +1 -1
  184. package/templates/.agents/rules/README.zh-CN.md +1 -1
  185. package/templates/.agents/rules/issue-pr-commands.en.md +1 -1
  186. package/templates/.agents/rules/issue-pr-commands.zh-CN.md +1 -1
  187. package/templates/.agents/rules/issue-sync.en.md +2 -0
  188. package/templates/.agents/rules/issue-sync.zh-CN.md +2 -0
  189. package/templates/.agents/rules/local-artifact-repair.en.md +9 -2
  190. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +9 -2
  191. package/templates/.agents/rules/pr-sync.en.md +10 -4
  192. package/templates/.agents/rules/pr-sync.zh-CN.md +17 -4
  193. package/templates/.agents/rules/task-short-id.en.md +1 -4
  194. package/templates/.agents/rules/task-short-id.zh-CN.md +1 -3
  195. package/templates/.agents/skills/analyze-task/SKILL.en.md +13 -3
  196. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +10 -3
  197. package/templates/.agents/skills/check-task/SKILL.en.md +16 -37
  198. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +16 -37
  199. package/templates/.agents/skills/code-task/SKILL.en.md +27 -8
  200. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +29 -10
  201. package/templates/.agents/skills/code-task/config/verify.en.json +1 -0
  202. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -0
  203. package/templates/.agents/skills/commit/reference/issue-metadata-sync.en.md +2 -0
  204. package/templates/.agents/skills/commit/reference/issue-metadata-sync.zh-CN.md +2 -0
  205. package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +3 -3
  206. package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +3 -3
  207. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +5 -3
  208. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +5 -3
  209. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +4 -3
  210. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +4 -3
  211. package/templates/.agents/skills/complete-task/SKILL.en.md +21 -3
  212. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +20 -3
  213. package/templates/.agents/skills/create-pr/SKILL.en.md +7 -6
  214. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +7 -7
  215. package/templates/.agents/skills/create-pr/config/verify.json +1 -0
  216. package/templates/.agents/skills/create-pr/reference/change-report.en.md +16 -4
  217. package/templates/.agents/skills/create-pr/reference/change-report.zh-CN.md +16 -4
  218. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +5 -3
  219. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +5 -3
  220. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +1 -1
  221. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +1 -1
  222. package/templates/.agents/skills/create-pr/scripts/change-report.mjs +3 -0
  223. package/templates/.agents/skills/plan-task/SKILL.en.md +13 -3
  224. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +10 -3
  225. package/templates/.agents/skills/review-analysis/SKILL.en.md +3 -3
  226. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +3 -3
  227. package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
  228. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
  229. package/templates/.agents/skills/review-plan/SKILL.en.md +3 -3
  230. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +3 -3
  231. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +4 -2
  232. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +4 -2
  233. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -2
  234. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +2 -2
  235. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +6 -0
  236. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +6 -0
  237. package/templates/.agents/templates/task.en.md +18 -1
  238. package/templates/.agents/templates/task.zh-CN.md +18 -1
  239. package/templates/.github/workflows/pr-label.yml +7 -26
@@ -5,17 +5,21 @@ import { parseTypedTaskFrontmatter } from '../task/frontmatter.js';
5
5
  import { resolveTaskRef } from '../task/resolve-ref.js';
6
6
  import { extractSection } from '../task/sections.js';
7
7
  import { captureTaskWriteMetadata, writeTask } from '../task/write.js';
8
- import { inspectPlatformChangeRequest, inspectPlatformIssueClosingChangeRequests, registerPlatformCapabilities } from './adapters.js';
9
- import { parseGitHubRemote, resolvePlatformContext } from './context.js';
8
+ import { parseGitHubRemote, resolvePlatformContext, resolvePlatformProviderContext } from './context.js';
10
9
  import { createGitHubClient } from './github-client.js';
11
10
  import { inspectPlatformIssue } from './issues.js';
11
+ import { inspectGitHubIssue } from './issues.js';
12
12
  import { planPullRequestMetadata } from './pull-request-metadata.js';
13
+ import { extractPullRequestFileNames, planInLabelUpdate, syncLabelDelta, validateInLabelMapping, validateRepositoryLabelPayload } from './in-label-sync.js';
13
14
  import { platformResult } from './types.js';
14
15
  import { inspectCompletionArtifacts } from '../task/finalization-artifacts.js';
15
16
  import { TaskExecutionLockError, withTaskExecutionLock } from '../task/task-execution-lock.js';
16
17
  import { resolveDeliveryTarget } from '../task/delivery-target.js';
17
18
  import { mergeOperationWarnings } from '../task/operation-outcome.js';
18
19
  import { buildBoundFact, buildSkippedFact, factFrontmatterMutation, readPrDeliveryFact } from '../task/pr-delivery-fact.js';
20
+ import { providerError, providerOperationContext, providerStatus, providerResourceToken, providerResourceIdentity, resourceIdentity, unsupportedProviderOperation } from './provider-bridge.js';
21
+ import { isResourceIdentity, resourceIdentityEquals, resourceIdentityNumber } from './resource-identity.js';
22
+ import { taskIssueIdentity, taskIssueIdentityError } from './task-identities.js';
19
23
  function warningResultForPrimary(primaryResult) {
20
24
  if (primaryResult === 'pr_created')
21
25
  return 'pr_created_with_warnings';
@@ -105,9 +109,15 @@ function normalizeClosingPullRequest(node, repository) {
105
109
  function repositoryKey(repository) {
106
110
  return repository.trim().toLowerCase();
107
111
  }
112
+ function isUsableResourceIdentity(identity) {
113
+ return isResourceIdentity(identity);
114
+ }
115
+ function identityLabel(identity) {
116
+ return identity.kind === 'number' ? `PR #${identity.value}` : `PR ${identity.kind}:${identity.value}`;
117
+ }
108
118
  function identitySignature(pullRequest) {
109
119
  return [
110
- pullRequest.repository, pullRequest.number, pullRequest.nodeId, pullRequest.url,
120
+ pullRequest.repository, JSON.stringify(pullRequestIdentity(pullRequest)), pullRequest.nodeId, pullRequest.url,
111
121
  pullRequest.head.repository, pullRequest.head.ref, pullRequest.head.sha,
112
122
  pullRequest.base.repository, pullRequest.base.ref, pullRequest.base.sha,
113
123
  pullRequest.mergedAt || '', pullRequest.mergeCommitSha || ''
@@ -119,48 +129,52 @@ function hasCompleteExternalIdentity(pullRequest) {
119
129
  pullRequest.head.repository, pullRequest.head.ref, pullRequest.head.sha,
120
130
  pullRequest.base.repository, pullRequest.base.ref, pullRequest.base.sha
121
131
  ];
122
- return Number.isInteger(pullRequest.number) && pullRequest.number > 0 &&
123
- required.every((value) => Boolean(value?.trim())) &&
132
+ return isUsableResourceIdentity(pullRequestIdentity(pullRequest)) && required.every((value) => Boolean(value?.trim())) &&
124
133
  (pullRequest.state === 'open' || Boolean(pullRequest.mergedAt) === Boolean(pullRequest.mergeCommitSha));
125
134
  }
126
- function selectExternalPullRequest(candidates, repository, existingPrNumber, explicitPrNumber) {
135
+ function selectExternalPullRequest(candidates, repository, existingPrIdentity, explicitPrIdentity) {
136
+ const existingIdentity = typeof existingPrIdentity === 'number'
137
+ ? resourceIdentity(existingPrIdentity) : existingPrIdentity;
138
+ const explicitIdentity = typeof explicitPrIdentity === 'number'
139
+ ? resourceIdentity(explicitPrIdentity) : explicitPrIdentity;
127
140
  const invalid = candidates.find((candidate) => !hasCompleteExternalIdentity(candidate));
128
141
  if (invalid)
129
142
  return {
130
143
  status: 'failed', code: 'PR_IDENTITY_INVALID',
131
144
  message: `Closing PR #${invalid.number} lacks required identity`, candidates, eligible: []
132
145
  };
133
- const byNumber = new Map();
146
+ const byIdentity = new Map();
134
147
  for (const candidate of candidates) {
135
148
  const signature = identitySignature(candidate);
136
- const prior = byNumber.get(candidate.number);
149
+ const identityKey = JSON.stringify(pullRequestIdentity(candidate));
150
+ const prior = byIdentity.get(identityKey);
137
151
  if (prior && prior !== signature)
138
152
  return {
139
153
  status: 'failed', code: 'PR_IDENTITY_INVALID',
140
154
  message: `Closing PR #${candidate.number} has conflicting identities`, candidates, eligible: []
141
155
  };
142
- byNumber.set(candidate.number, signature);
156
+ byIdentity.set(identityKey, signature);
143
157
  }
144
- const unique = candidates.filter((candidate, index) => candidates.findIndex((other) => other.number === candidate.number) === index);
158
+ const unique = candidates.filter((candidate, index) => candidates.findIndex((other) => resourceIdentityEquals(pullRequestIdentity(other), pullRequestIdentity(candidate))) === index);
145
159
  const wantedRepository = repositoryKey(repository);
146
160
  const eligible = unique.filter((candidate) => repositoryKey(candidate.repository) === wantedRepository &&
147
161
  repositoryKey(candidate.base.repository) === wantedRepository &&
148
162
  candidate.state === 'closed' &&
149
163
  Boolean(candidate.mergedAt && candidate.mergeCommitSha));
150
- if (explicitPrNumber !== null) {
151
- const selected = unique.find((candidate) => candidate.number === explicitPrNumber);
164
+ if (explicitIdentity !== null) {
165
+ const selected = unique.find((candidate) => resourceIdentityEquals(pullRequestIdentity(candidate), explicitIdentity));
152
166
  if (!selected)
153
- return { status: 'failed', code: 'PR_NOT_FOUND', message: `PR #${explicitPrNumber} is not a closing PR`, candidates, eligible };
154
- if (!eligible.some((candidate) => candidate.number === explicitPrNumber)) {
155
- return { status: 'failed', code: 'PR_IDENTITY_INVALID', message: `PR #${explicitPrNumber} is not an eligible merged PR`, candidates, eligible };
167
+ return { status: 'failed', code: 'PR_NOT_FOUND', message: `PR ${identityLabel(explicitIdentity)} is not a closing PR`, candidates, eligible };
168
+ if (!eligible.some((candidate) => resourceIdentityEquals(pullRequestIdentity(candidate), explicitIdentity))) {
169
+ return { status: 'failed', code: 'PR_IDENTITY_INVALID', message: `PR ${identityLabel(explicitIdentity)} is not an eligible merged PR`, candidates, eligible };
156
170
  }
157
- if (existingPrNumber !== null && existingPrNumber !== explicitPrNumber) {
158
- return { status: 'failed', code: 'PR_BIND_CONFLICT', message: `Task is already bound to PR #${existingPrNumber}`, candidates, eligible };
171
+ if (existingIdentity !== null && !resourceIdentityEquals(existingIdentity, explicitIdentity)) {
172
+ return { status: 'failed', code: 'PR_BIND_CONFLICT', message: `Task is already bound to ${identityLabel(existingIdentity)}`, candidates, eligible };
159
173
  }
160
174
  return { status: 'selected', source: 'explicit', selected, candidates, eligible };
161
175
  }
162
- if (existingPrNumber !== null && eligible.length === 1 && existingPrNumber !== eligible[0].number) {
163
- return { status: 'failed', code: 'PR_BIND_CONFLICT', message: `Task is already bound to PR #${existingPrNumber}`, candidates, eligible };
176
+ if (existingIdentity !== null && eligible.length === 1 && !resourceIdentityEquals(existingIdentity, pullRequestIdentity(eligible[0]))) {
177
+ return { status: 'failed', code: 'PR_BIND_CONFLICT', message: `Task is already bound to ${identityLabel(existingIdentity)}`, candidates, eligible };
164
178
  }
165
179
  if (eligible.length === 0)
166
180
  return { status: 'normal', candidates, eligible: [] };
@@ -238,64 +252,6 @@ function selectPullRequest(remotes, repository, head, base, expectedSha) {
238
252
  return { status: 'resolved', pullRequest: matches[0] };
239
253
  return { status: matches.length === 0 ? 'missing' : 'ambiguous', pullRequest: null };
240
254
  }
241
- function repositoryHead(cwd) {
242
- return execFileSync('git', ['rev-parse', 'HEAD'], {
243
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
244
- }).trim();
245
- }
246
- function remoteBranchHead(cwd, repository, head) {
247
- const wanted = expectedHead(repository, head);
248
- if (!wanted.ref || !/^[A-Za-z0-9._/-]+$/.test(wanted.ref))
249
- return {
250
- ok: false,
251
- error: { code: 'PR_HEAD_INVALID', message: 'Pull request head ref is invalid', retryable: false }
252
- };
253
- const remotes = configuredGitRemotes(cwd);
254
- const remote = remotes.find((item) => repositoryKey(item.repository) === repositoryKey(wanted.repository))
255
- ?? remotes.find((item) => item.name === 'origin');
256
- if (!remote)
257
- return {
258
- ok: false,
259
- error: { code: 'PR_REMOTE_BRANCH_MISSING', message: `No configured remote can verify ${wanted.repository}:${wanted.ref}`, retryable: false }
260
- };
261
- try {
262
- const output = execFileSync('git', ['ls-remote', '--refs', remote.name, `refs/heads/${wanted.ref}`], {
263
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
264
- }).trim();
265
- const match = output.split(/\r?\n/).map((line) => line.trim()).find((line) => line.endsWith(`refs/heads/${wanted.ref}`));
266
- const sha = match?.split(/\s+/)[0] || null;
267
- if (!sha || !/^[a-f0-9]{40}$/i.test(sha))
268
- return {
269
- ok: false,
270
- error: { code: 'PR_REMOTE_BRANCH_MISSING', message: `Remote branch ${wanted.repository}:${wanted.ref} does not exist`, retryable: false }
271
- };
272
- return { ok: true, value: sha };
273
- }
274
- catch (error) {
275
- return {
276
- ok: false,
277
- error: { code: 'PR_REMOTE_BRANCH_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: true }
278
- };
279
- }
280
- }
281
- function verifyCreateHead(base, head) {
282
- let localHead;
283
- try {
284
- localHead = repositoryHead(base.resolved.repoRoot);
285
- }
286
- catch (error) {
287
- return { ok: false, error: { code: 'PR_LOCAL_HEAD_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: false } };
288
- }
289
- const remote = remoteBranchHead(base.resolved.repoRoot, base.context.platform.repository, head);
290
- if (!remote.ok)
291
- return remote;
292
- if (remote.value !== localHead)
293
- return {
294
- ok: false,
295
- error: { code: 'PR_REMOTE_HEAD_MISMATCH', message: `Remote head ${remote.value} does not match local HEAD ${localHead}`, retryable: false }
296
- };
297
- return { ok: true, value: localHead };
298
- }
299
255
  function validateBoundPullRequest(base, pullRequest, head, baseRef, expectedSha) {
300
256
  if (!pullRequest)
301
257
  return {
@@ -376,7 +332,7 @@ function validateExternalMergedEvidence(initial, rechecked) {
376
332
  };
377
333
  return { ok: true };
378
334
  }
379
- function resolvedContext(taskRef, options) {
335
+ async function resolvedContext(taskRef, options) {
380
336
  const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
381
337
  if (!resolved.ok)
382
338
  return { ok: false, output: result('failed', resolved.taskId, null, null, {
@@ -384,24 +340,87 @@ function resolvedContext(taskRef, options) {
384
340
  }) };
385
341
  const content = fs.readFileSync(resolved.taskMdPath, 'utf8');
386
342
  const frontmatter = parseTypedTaskFrontmatter(content);
387
- const factRead = readPrDeliveryFact(frontmatter);
343
+ const factRead = readPrDeliveryFact(frontmatter, options.runtimeVersion);
388
344
  if (factRead.status === 'invalid')
389
345
  return { ok: false, output: result('failed', resolved.taskId, null, null, {
390
- error: { code: 'PR_DELIVERY_FACT_INVALID', message: factRead.error.message, retryable: false }
346
+ error: { code: factRead.error.code, message: factRead.error.message, retryable: false }
391
347
  }) };
392
348
  const fact = factRead.status === 'valid' ? factRead.fact : null;
393
- const issue = Number(frontmatter.issue_number);
394
- const pr = fact?.state === 'bound' ? fact.identity.number : NaN;
395
- const issueNumber = Number.isInteger(issue) && issue > 0 ? issue : null;
396
- const prNumber = Number.isInteger(pr) && pr > 0 ? pr : null;
397
- const client = options.client || createGitHubClient();
398
- const context = resolvePlatformContext({ cwd: resolved.repoRoot, client });
349
+ let issueIdentity;
350
+ try {
351
+ issueIdentity = taskIssueIdentity(frontmatter, undefined, options.runtimeVersion);
352
+ }
353
+ catch (error) {
354
+ return { ok: false, output: result('failed', resolved.taskId, null, null, { error: { ...taskIssueIdentityError(error), retryable: false } }) };
355
+ }
356
+ const issueNumber = resourceIdentityNumber(issueIdentity);
357
+ const prIdentity = fact?.state === 'bound' ? fact.identity.resource : null;
358
+ const prNumber = resourceIdentityNumber(prIdentity);
359
+ const client = options.client;
360
+ const loaded = await resolvePlatformProviderContext({ cwd: resolved.repoRoot, client });
361
+ const context = loaded.ok ? loaded.value.context : loaded.context;
399
362
  const usable = (context.status === 'no-op' || context.status === 'degraded') && context.platform.repository;
400
- if (!usable)
363
+ if (!usable || !loaded.ok)
401
364
  return { ok: false, output: result(context.status, resolved.taskId, issueNumber, prNumber, {
402
365
  platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
403
366
  }) };
404
- return { ok: true, resolved, content, frontmatter, fact, issueNumber, prNumber, client, context };
367
+ return { ok: true, resolved, content, frontmatter, fact, issueIdentity, issueNumber, prIdentity, prNumber, client, context, provider: loaded.value.provider, providerType: loaded.value.providerType, loadedContext: loaded.value };
368
+ }
369
+ function normalizeProviderPullRequest(remote, repository, fallbackNumber, fallback) {
370
+ const head = remote.head || fallback?.head || { repository, ref: '', sha: remote.headSha || '' };
371
+ const base = remote.base || fallback?.base || { repository, ref: '', sha: remote.baseSha || '' };
372
+ const identity = remote.identity || fallback?.identity || (remote.number ? { kind: 'number', value: remote.number } : { kind: 'id', value: remote.id });
373
+ const snapshot = {
374
+ repository,
375
+ number: remote.number ?? resourceIdentityNumber(identity) ?? fallbackNumber,
376
+ nodeId: remote.id,
377
+ url: remote.displayUrl || fallback?.url || '',
378
+ state: remote.state === 'closed' ? 'closed' : 'open',
379
+ title: remote.title,
380
+ body: remote.body,
381
+ draft: remote.draft ?? fallback?.draft ?? false,
382
+ head: { ...head, sha: remote.headSha || head.sha },
383
+ base: { ...base, sha: remote.baseSha || base.sha },
384
+ mergedAt: remote.mergedAt ?? fallback?.mergedAt ?? null,
385
+ mergeCommitSha: remote.mergeCommitSha ?? fallback?.mergeCommitSha ?? null,
386
+ labels: [...(remote.labels || fallback?.labels || [])].sort(),
387
+ assignees: [...(remote.assignees || fallback?.assignees || [])].sort(),
388
+ milestone: remote.milestone ?? fallback?.milestone ?? null,
389
+ mergeability: remote.mergeability || fallback?.mergeability || { state: 'unknown', detail: null }
390
+ };
391
+ Object.defineProperty(snapshot, 'identity', { value: identity, enumerable: false, configurable: true });
392
+ return snapshot;
393
+ }
394
+ function pullRequestIdentity(pullRequest) {
395
+ return pullRequest.identity || { kind: 'number', value: pullRequest.number };
396
+ }
397
+ function providerPullRequestError(base, error, prNumber) {
398
+ return result(error.code.startsWith('IN_LABEL_SYNC') ? 'blocked' : providerStatus(error), base.resolved.taskId, base.issueNumber, prNumber, {
399
+ changed: error.code.startsWith('IN_LABEL_SYNC'),
400
+ platform: base.context.platform,
401
+ capabilities: base.context.capabilities,
402
+ resource: { kind: 'pull-request', number: prNumber },
403
+ error: providerError(error, 'PLATFORM_PROVIDER_OPERATION_FAILED')
404
+ });
405
+ }
406
+ async function inspectExternalPullRequest(base, prIdentity) {
407
+ const prNumber = resourceIdentityNumber(prIdentity);
408
+ const fallback = base.fact?.state === 'bound' && resourceIdentityEquals(base.fact.identity.resource, prIdentity)
409
+ ? base.fact.identity
410
+ : null;
411
+ const inspected = base.provider.changeRequests?.inspect
412
+ ? await base.provider.changeRequests.inspect({ context: providerOperationContext(base.loadedContext), target: prIdentity })
413
+ : unsupportedProviderOperation(base.provider, 'changeRequests.inspect');
414
+ if (!inspected.ok)
415
+ return providerPullRequestError(base, inspected.error, prNumber);
416
+ const pullRequest = normalizeProviderPullRequest(inspected.value, base.context.platform.repository, prNumber || 0, fallback);
417
+ return result('no-op', base.resolved.taskId, base.issueNumber, prNumber, {
418
+ platform: base.context.platform,
419
+ capabilities: base.context.capabilities,
420
+ resource: { kind: 'pull-request', number: prNumber, identity: prIdentity },
421
+ pullRequest,
422
+ error: null
423
+ });
405
424
  }
406
425
  function inspectGitHubPullRequest(client, repository, number, cwd) {
407
426
  const fetched = client.json(['api', `repos/${repository}/pulls/${number}`], { cwd });
@@ -412,189 +431,340 @@ function inspectGitHubPullRequest(client, repository, number, cwd) {
412
431
  ? { ok: true, value: pullRequest }
413
432
  : { ok: false, error: { code: 'PR_IDENTITY_INVALID', message: 'Remote resource is not a valid pull request', retryable: false } };
414
433
  }
415
- function configuredGitRemotes(cwd) {
434
+ async function inspectPlatformPullRequest(taskRef, options = {}) {
435
+ const base = await resolvedContext(taskRef, options);
436
+ if (!base.ok)
437
+ return base.output;
438
+ if (!base.fact)
439
+ return result('no-op', base.resolved.taskId, base.issueNumber, null, {
440
+ error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
441
+ });
442
+ if (!base.prIdentity)
443
+ return result('no-op', base.resolved.taskId, base.issueNumber, null, {
444
+ platform: base.context.platform, capabilities: base.context.capabilities,
445
+ error: { code: 'PR_NOT_LINKED', message: 'Task has no verified bound pull request', retryable: false }
446
+ });
447
+ return inspectExternalPullRequest(base, base.prIdentity);
448
+ }
449
+ async function inspectPlatformPullRequestByNumber(prNumber, options = {}) {
450
+ const loaded = await resolvePlatformProviderContext({ cwd: options.cwd || process.cwd(), client: options.client });
451
+ const context = loaded.ok ? loaded.value.context : loaded.context;
452
+ const usable = (context.status === 'no-op' || context.status === 'degraded') && context.platform.repository;
453
+ if (!usable) {
454
+ return result(context.status, null, null, null, {
455
+ platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
456
+ });
457
+ }
458
+ let identity;
416
459
  try {
417
- return execFileSync('git', ['remote'], {
418
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
419
- }).split(/\r?\n/).filter(Boolean).flatMap((name) => {
420
- try {
421
- const url = execFileSync('git', ['config', '--get', `remote.${name}.url`], {
422
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
423
- }).trim();
424
- const repository = parseGitHubRemote(url);
425
- return repository ? [{ name, url, repository }] : [];
426
- }
427
- catch {
428
- return [];
429
- }
460
+ identity = loaded.ok ? providerResourceToken(loaded.value.provider, 'pull-request', String(prNumber)) : resourceIdentity(prNumber);
461
+ }
462
+ catch (error) {
463
+ return result('failed', null, null, null, {
464
+ platform: context.platform, capabilities: context.capabilities,
465
+ error: { code: 'PLATFORM_IDENTITY_TOKEN_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
430
466
  });
431
467
  }
432
- catch {
433
- return [];
468
+ if (loaded.ok) {
469
+ const inspected = loaded.value.provider.changeRequests?.inspect
470
+ ? await loaded.value.provider.changeRequests.inspect({ context: providerOperationContext(loaded.value), target: identity })
471
+ : unsupportedProviderOperation(loaded.value.provider, 'changeRequests.inspect');
472
+ const number = resourceIdentityNumber(identity);
473
+ if (!inspected.ok)
474
+ return result(providerStatus(inspected.error), null, null, number, {
475
+ platform: context.platform, capabilities: context.capabilities,
476
+ resource: { kind: 'pull-request', number, identity }, error: providerError(inspected.error, 'PLATFORM_PROVIDER_OPERATION_FAILED')
477
+ });
478
+ return result('no-op', null, null, number, {
479
+ platform: context.platform, capabilities: context.capabilities,
480
+ resource: { kind: 'pull-request', number, identity },
481
+ pullRequest: normalizeProviderPullRequest(inspected.value, context.platform.repository, number || 0), error: null
482
+ });
434
483
  }
484
+ return result('failed', null, null, null, {
485
+ platform: context.platform, capabilities: context.capabilities,
486
+ resource: { kind: 'pull-request', number: null },
487
+ error: { code: 'PLATFORM_PROVIDER_LOAD_FAILED', message: 'Selected provider failed to load', retryable: false }
488
+ });
435
489
  }
436
- function rewriteGitHubRemote(remote, repository) {
437
- const match = remote.trim().match(/^(https?:\/\/github\.com\/|ssh:\/\/(?:git@)?github\.com\/|git@github\.com:)[^/\s]+\/[^/\s]+(?:\.git)?$/i);
438
- return match ? `${match[1]}${repository}.git` : null;
490
+ const CLOSING_ISSUES_QUERY = 'query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){closingIssuesReferences(first:100,after:$cursor){nodes{number} pageInfo{hasNextPage endCursor}}}}}';
491
+ function readInLabelMapping(repoRoot) {
492
+ try {
493
+ const config = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8'));
494
+ const mapping = validateInLabelMapping(config.labels?.in);
495
+ return mapping.ok ? { ok: true, value: mapping.value } : mapping;
496
+ }
497
+ catch (error) {
498
+ return {
499
+ ok: false,
500
+ error: { code: 'IN_LABEL_SYNC_CONFIG_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
501
+ };
502
+ }
439
503
  }
440
- function resolveGitHubChangeRequestGitEvidence({ cwd, repository, pullRequest }) {
441
- if (pullRequest.repository !== repository ||
442
- pullRequest.base.repository !== repository ||
443
- pullRequest.number <= 0) {
504
+ function taskInLabelTarget(repoRoot, frontmatter, currentLabels, repositoryLabels) {
505
+ const baseRef = typeof frontmatter.delivery_base_ref === 'string' ? frontmatter.delivery_base_ref.trim() : '';
506
+ if (!baseRef)
444
507
  return {
445
508
  ok: false,
446
- error: {
447
- code: 'PR_MERGE_EVIDENCE_SOURCE_UNAVAILABLE',
448
- message: 'Pull request repository identity is inconsistent',
449
- retryable: false
450
- }
509
+ error: { code: 'IN_LABEL_SYNC_BASE_MISSING', message: 'Task has no delivery_base_ref for in-label evidence', retryable: false }
451
510
  };
511
+ let changedFiles;
512
+ try {
513
+ changedFiles = execFileSync('git', ['diff', `${baseRef}...HEAD`, '--name-only'], {
514
+ cwd: repoRoot, encoding: 'utf8'
515
+ }).trim().split(/\r?\n/).filter(Boolean);
452
516
  }
453
- const reviewedHeadRef = `refs/pull/${pullRequest.number}/head`;
454
- const targetHeadRef = `refs/heads/${pullRequest.base.ref}`;
455
- const refValid = (ref) => {
456
- try {
457
- execFileSync('git', ['check-ref-format', ref], {
458
- cwd, encoding: 'utf8', stdio: ['ignore', 'ignore', 'ignore']
459
- });
460
- return true;
461
- }
462
- catch {
463
- return false;
464
- }
465
- };
466
- if (!refValid(reviewedHeadRef) || !refValid(targetHeadRef)) {
517
+ catch (error) {
467
518
  return {
468
519
  ok: false,
469
- error: {
470
- code: 'PR_MERGE_EVIDENCE_SOURCE_UNAVAILABLE',
471
- message: 'Pull request Git refs are invalid',
472
- retryable: false
473
- }
520
+ error: { code: 'IN_LABEL_SYNC_EVIDENCE_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: false }
474
521
  };
475
522
  }
476
- const remotes = configuredGitRemotes(cwd);
477
- const exact = remotes
478
- .filter((remote) => remote.repository === repository)
479
- .sort((left, right) => Number(right.name === 'origin') - Number(left.name === 'origin') ||
480
- left.name.localeCompare(right.name))[0];
481
- const origin = remotes.find((remote) => remote.name === 'origin');
482
- const remoteUrl = exact?.url || (origin ? rewriteGitHubRemote(origin.url, repository) : null);
483
- if (!remoteUrl) {
523
+ const mapping = readInLabelMapping(repoRoot);
524
+ if (!mapping.ok)
525
+ return mapping;
526
+ const planned = planInLabelUpdate({ changedFiles, currentLabels, mapping: mapping.value, repositoryLabels });
527
+ if (planned.error)
528
+ return { ok: false, error: planned.error };
529
+ return { ok: true, value: planned.target };
530
+ }
531
+ function inspectClosingIssueNumbers(client, repository, pullRequest, cwd) {
532
+ const [owner, name] = repository.split('/');
533
+ if (!owner || !name)
484
534
  return {
485
535
  ok: false,
486
- error: {
487
- code: 'PR_MERGE_EVIDENCE_SOURCE_UNAVAILABLE',
488
- message: `No GitHub remote can provide evidence for ${repository}`,
489
- retryable: false
490
- }
536
+ error: { code: 'IN_LABEL_SYNC_REPOSITORY_INVALID', message: 'Repository identity is invalid', retryable: false }
491
537
  };
538
+ let cursor = null;
539
+ const numbers = [];
540
+ for (;;) {
541
+ const args = ['api', 'graphql', '-f', `query=${CLOSING_ISSUES_QUERY}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `number=${pullRequest}`];
542
+ if (cursor)
543
+ args.push('-F', `cursor=${cursor}`);
544
+ const response = client.json(args, { cwd });
545
+ if (!response.ok)
546
+ return response;
547
+ const connection = response.value?.data?.repository?.pullRequest?.closingIssuesReferences;
548
+ if (!connection || !Array.isArray(connection.nodes) || !connection.pageInfo)
549
+ return {
550
+ ok: false,
551
+ error: { code: 'IN_LABEL_SYNC_ASSOCIATION_INVALID', message: 'Closing Issue response is incomplete', retryable: false }
552
+ };
553
+ for (const node of connection.nodes) {
554
+ const issue = Number(node?.number);
555
+ if (!Number.isSafeInteger(issue) || issue <= 0)
556
+ return {
557
+ ok: false,
558
+ error: { code: 'IN_LABEL_SYNC_ASSOCIATION_INVALID', message: 'Closing Issue identity is invalid', retryable: false }
559
+ };
560
+ numbers.push(issue);
561
+ }
562
+ if (!connection.pageInfo.hasNextPage)
563
+ return { ok: true, value: [...new Set(numbers)] };
564
+ if (!connection.pageInfo.endCursor || connection.pageInfo.endCursor === cursor)
565
+ return {
566
+ ok: false,
567
+ error: { code: 'IN_LABEL_SYNC_ASSOCIATION_INVALID', message: 'Closing Issue pagination cursor is invalid', retryable: false }
568
+ };
569
+ cursor = connection.pageInfo.endCursor;
492
570
  }
493
- return {
494
- ok: true,
495
- value: { remoteUrl, reviewedHeadRef, targetHeadRef }
571
+ }
572
+ function inspectRepositoryLabels(client, repository, cwd) {
573
+ const response = client.json(['api', '--paginate', '--slurp', `repos/${repository}/labels?per_page=100`], { cwd });
574
+ if (!response.ok)
575
+ return response;
576
+ const labels = validateRepositoryLabelPayload(response.value);
577
+ return labels.ok ? { ok: true, value: new Set(labels.value) } : labels;
578
+ }
579
+ function inspectPullRequestFiles(client, repository, pullRequest, cwd) {
580
+ const response = client.json(['api', '--paginate', '--slurp', `repos/${repository}/pulls/${pullRequest}/files?per_page=100`], { cwd });
581
+ if (!response.ok)
582
+ return response;
583
+ const files = extractPullRequestFileNames(response.value);
584
+ return files ? { ok: true, value: files } : {
585
+ ok: false,
586
+ error: { code: 'IN_LABEL_SYNC_FILES_INVALID', message: 'Pull request files response is incomplete', retryable: false }
496
587
  };
497
588
  }
498
- registerPlatformCapabilities('github', {
499
- inspectChangeRequest({ client, repository, number, cwd }) {
500
- return inspectGitHubPullRequest(client || createGitHubClient(), repository, number, cwd);
501
- },
502
- inspectIssueClosingChangeRequests({ client, repository, issueNumber, cwd }) {
503
- return inspectGitHubIssueClosingChangeRequests(client || createGitHubClient(), repository, issueNumber, cwd);
504
- },
505
- resolveChangeRequestGitEvidence(context) {
506
- return resolveGitHubChangeRequestGitEvidence(context);
589
+ function inspectTaskInLabelTarget(repoRoot, frontmatter, pullRequest, repository, client) {
590
+ const baseRef = typeof frontmatter.delivery_base_ref === 'string' ? frontmatter.delivery_base_ref.trim() : '';
591
+ if (!baseRef)
592
+ return {
593
+ ok: false,
594
+ error: { code: 'IN_LABEL_SYNC_BASE_MISSING', message: 'Task has no delivery_base_ref for in-label evidence', retryable: false }
595
+ };
596
+ let changedFiles;
597
+ try {
598
+ changedFiles = execFileSync('git', ['diff', `${baseRef}...HEAD`, '--name-only'], {
599
+ cwd: repoRoot, encoding: 'utf8'
600
+ }).trim().split(/\r?\n/).filter(Boolean);
507
601
  }
508
- });
509
- function locatePullRequest(base, head, target, expectedSha) {
510
- const repository = base.context.platform.repository;
511
- const listed = base.client.json([
512
- 'api', `repos/${repository}/pulls?state=open&base=${encodeURIComponent(target)}&per_page=100`
513
- ], { cwd: base.resolved.repoRoot });
514
- if (!listed.ok)
515
- return listed;
516
- const selected = selectPullRequest(listed.value, repository, head, target, expectedSha);
517
- return selected.status === 'resolved'
518
- ? { ok: true, value: selected.pullRequest }
519
- : { ok: false, error: {
520
- code: selected.status === 'ambiguous' ? 'PR_IDENTITY_AMBIGUOUS'
521
- : selected.status === 'head-mismatch' ? 'PR_HEAD_SHA_MISMATCH' : 'PR_NOT_FOUND',
522
- message: selected.status === 'ambiguous' ? 'Multiple pull requests match the exact head/base identity'
523
- : selected.status === 'head-mismatch' ? 'A pull request matches head/base but not the expected head SHA'
524
- : 'No pull request matches the exact head/base identity',
525
- retryable: false
526
- } };
527
- }
528
- function inspectPlatformPullRequest(taskRef, options = {}) {
529
- const base = resolvedContext(taskRef, options);
530
- if (!base.ok)
531
- return base.output;
532
- if (!base.fact)
533
- return result('no-op', base.resolved.taskId, base.issueNumber, null, {
534
- error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
535
- });
536
- if (!base.prNumber)
537
- return result('no-op', base.resolved.taskId, base.issueNumber, null, {
538
- platform: base.context.platform, capabilities: base.context.capabilities,
539
- error: { code: 'PR_NOT_LINKED', message: 'Task has no verified bound pull request', retryable: false }
540
- });
541
- const fetched = inspectPlatformChangeRequest(base.context.platform.type, {
542
- cwd: base.resolved.repoRoot,
543
- repository: base.context.platform.repository,
544
- number: base.prNumber,
545
- client: options.client || base.client
546
- });
547
- if (!fetched.ok || !fetched.value) {
548
- const error = fetched.error || {
549
- code: 'PR_INSPECTION_INVALID',
550
- message: 'Platform adapter returned no change-request snapshot',
551
- retryable: false
602
+ catch (error) {
603
+ return {
604
+ ok: false,
605
+ error: { code: 'IN_LABEL_SYNC_EVIDENCE_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: false }
552
606
  };
553
- return result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
554
- platform: base.context.platform, capabilities: base.context.capabilities,
555
- resource: { kind: 'pull-request', number: base.prNumber }, error
556
- });
557
607
  }
558
- return result('no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
559
- platform: base.context.platform, capabilities: base.context.capabilities,
560
- resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: fetched.value, error: null
608
+ const mapping = readInLabelMapping(repoRoot);
609
+ if (!mapping.ok)
610
+ return mapping;
611
+ const labels = inspectRepositoryLabels(client, repository, repoRoot);
612
+ if (!labels.ok)
613
+ return labels;
614
+ const planned = planInLabelUpdate({
615
+ changedFiles, currentLabels: pullRequest.labels, mapping: mapping.value, repositoryLabels: labels.value
561
616
  });
617
+ if (planned.error)
618
+ return { ok: false, error: planned.error };
619
+ return { ok: true, value: { changedFiles, target: planned.target } };
620
+ }
621
+ function inLabelResource(kind, number, before, expected, after, effect) {
622
+ return { kind, number, before: [...before].sort(), expected: [...expected].sort(), after: after ? [...after].sort() : null, effect };
562
623
  }
563
- function inspectPlatformPullRequestByNumber(prNumber, options = {}) {
624
+ async function syncPlatformPullRequestInLabels(prNumber, options = {}) {
625
+ const cwd = path.resolve(options.cwd || process.cwd());
564
626
  const client = options.client || createGitHubClient();
565
- const context = resolvePlatformContext({ cwd: options.cwd || process.cwd(), client });
627
+ const context = await resolvePlatformContext({ cwd, client });
566
628
  const usable = (context.status === 'no-op' || context.status === 'degraded') && context.platform.repository;
567
- if (!usable) {
568
- return result(context.status, null, null, null, {
629
+ if (!usable)
630
+ return result(context.status, null, null, prNumber, {
569
631
  platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
570
632
  });
571
- }
572
- if (!Number.isInteger(prNumber) || prNumber <= 0) {
573
- return result('failed', null, null, null, {
633
+ if (!Number.isSafeInteger(prNumber) || prNumber <= 0)
634
+ return result('failed', null, null, prNumber, {
574
635
  platform: context.platform, capabilities: context.capabilities,
575
636
  error: { code: 'PR_NUMBER_INVALID', message: 'PR number must be positive', retryable: false }
576
637
  });
577
- }
578
- const fetched = inspectPlatformChangeRequest(context.platform.type, {
579
- cwd: options.cwd || process.cwd(),
580
- repository: context.platform.repository,
581
- number: prNumber,
582
- client
638
+ const repository = context.platform.repository;
639
+ const fetched = inspectGitHubPullRequest(client, repository, prNumber, cwd);
640
+ if (!fetched.ok)
641
+ return result(fetched.error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
642
+ platform: context.platform, capabilities: context.capabilities, resource: { kind: 'pull-request', number: prNumber }, error: fetched.error
643
+ });
644
+ const files = inspectPullRequestFiles(client, repository, prNumber, cwd);
645
+ if (!files.ok)
646
+ return result(files.error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
647
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
648
+ resource: { kind: 'pull-request', number: prNumber }, error: files.error
649
+ });
650
+ const mapping = readInLabelMapping(cwd);
651
+ if (!mapping.ok)
652
+ return result('failed', null, null, prNumber, {
653
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
654
+ resource: { kind: 'pull-request', number: prNumber }, error: mapping.error
655
+ });
656
+ const repositoryLabels = inspectRepositoryLabels(client, repository, cwd);
657
+ if (!repositoryLabels.ok)
658
+ return result(repositoryLabels.error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
659
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
660
+ resource: { kind: 'pull-request', number: prNumber }, error: repositoryLabels.error
661
+ });
662
+ const plannedTarget = planInLabelUpdate({
663
+ changedFiles: files.value, currentLabels: fetched.value.labels,
664
+ mapping: mapping.value, repositoryLabels: repositoryLabels.value
583
665
  });
584
- if (!fetched.ok || !fetched.value) {
585
- const error = fetched.error || {
586
- code: 'PR_INSPECTION_INVALID',
587
- message: 'Platform adapter returned no change-request snapshot',
588
- retryable: false
589
- };
590
- return result(error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
591
- platform: context.platform, capabilities: context.capabilities,
592
- resource: { kind: 'pull-request', number: prNumber }, error
666
+ if (plannedTarget.error)
667
+ return result('failed', null, null, prNumber, {
668
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
669
+ resource: { kind: 'pull-request', number: prNumber }, error: plannedTarget.error
593
670
  });
671
+ const target = plannedTarget.target;
672
+ const association = inspectClosingIssueNumbers(client, repository, prNumber, cwd);
673
+ if (!association.ok)
674
+ return result(association.error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
675
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
676
+ resource: { kind: 'pull-request', number: prNumber }, evidence: { kind: 'pull-request-files', pullRequestFiles: files.value }, error: association.error
677
+ });
678
+ const issueNumber = association.value.length === 1 ? association.value[0] : null;
679
+ let issue = null;
680
+ if (issueNumber) {
681
+ const fetchedIssue = inspectGitHubIssue(client, repository, issueNumber, cwd);
682
+ if (!fetchedIssue.ok)
683
+ return result(fetchedIssue.error.retryable ? 'blocked' : 'failed', null, issueNumber, prNumber, {
684
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
685
+ resource: { kind: 'issue', number: issueNumber }, evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value }, error: fetchedIssue.error
686
+ });
687
+ issue = fetchedIssue.value;
594
688
  }
595
- return result('no-op', null, null, prNumber, {
596
- platform: context.platform, capabilities: context.capabilities,
597
- resource: { kind: 'pull-request', number: prNumber }, pullRequest: fetched.value, error: null
689
+ const resources = issue
690
+ ? [
691
+ { kind: 'issue', number: issue.number, snapshot: issue },
692
+ { kind: 'pull-request', number: prNumber, snapshot: fetched.value }
693
+ ]
694
+ : [{ kind: 'pull-request', number: prNumber, snapshot: fetched.value }];
695
+ const plans = resources.map((resource) => ({
696
+ ...resource,
697
+ plan: planInLabelUpdate({
698
+ changedFiles: files.value, currentLabels: resource.snapshot.labels,
699
+ mapping: mapping.value, repositoryLabels: repositoryLabels.value
700
+ })
701
+ }));
702
+ const operations = [
703
+ { name: 'closing-issues', status: 'no-op', reasonCode: association.value.length === 1 ? null : association.value.length === 0 ? 'ISSUE_ASSOCIATION_NONE' : 'ISSUE_ASSOCIATION_AMBIGUOUS' },
704
+ ...plans.map((item) => ({
705
+ name: `labels:in:${item.kind}`,
706
+ status: !context.capabilities.triage ? 'skipped' : item.plan.changed ? 'planned' : 'no-op',
707
+ reasonCode: !context.capabilities.triage ? 'TRIAGE_REQUIRED' : null
708
+ }))
709
+ ];
710
+ const willWrite = context.capabilities.triage && !options.dryRun;
711
+ const resourcesState = plans.map((item) => inLabelResource(item.kind, item.number, item.snapshot.labels, target, item.plan.changed && willWrite ? null : item.snapshot.labels, item.plan.changed && willWrite ? 'unknown' : 'no-op'));
712
+ if (options.dryRun || !context.capabilities.triage)
713
+ return result(!context.capabilities.triage || association.value.length !== 1 ? 'degraded' : plans.some((item) => item.plan.changed) ? 'planned' : 'no-op', null, issueNumber, prNumber, {
714
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
715
+ resource: { kind: 'pull-request', number: prNumber }, operations, resources: resourcesState,
716
+ evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value }, error: null
717
+ });
718
+ let currentPullRequest = fetched.value;
719
+ let successfulWrites = 0;
720
+ for (const item of plans) {
721
+ if (!item.plan.changed)
722
+ continue;
723
+ const synced = syncLabelDelta(client, repository, item.number, cwd, item.snapshot.labels, item.plan.target);
724
+ if (synced.status === 'failed' || synced.status === 'blocked') {
725
+ const state = resourcesState.find((resource) => resource.kind === item.kind && resource.number === item.number);
726
+ state.effect = synced.status === 'blocked' ? 'unknown' : 'no-op';
727
+ state.after = synced.status === 'blocked' ? null : item.snapshot.labels;
728
+ const partial = successfulWrites > 0 || synced.status === 'blocked';
729
+ const error = partial && synced.error
730
+ ? { ...synced.error, code: 'IN_LABEL_SYNC_PARTIAL', message: `In-label synchronization is partial or unknown: ${synced.error.message}` }
731
+ : synced.error;
732
+ return result(partial ? 'blocked' : synced.status, null, issueNumber, prNumber, {
733
+ changed: successfulWrites > 0 || synced.changed, platform: context.platform, capabilities: context.capabilities,
734
+ pullRequest: currentPullRequest, resource: { kind: 'pull-request', number: prNumber }, operations, resources: resourcesState,
735
+ evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value }, error
736
+ });
737
+ }
738
+ successfulWrites += synced.changed ? 1 : 0;
739
+ const reread = item.kind === 'issue'
740
+ ? inspectGitHubIssue(client, repository, item.number, cwd)
741
+ : inspectGitHubPullRequest(client, repository, item.number, cwd);
742
+ if (!reread.ok)
743
+ return result('blocked', null, issueNumber, prNumber, {
744
+ changed: true, platform: context.platform, capabilities: context.capabilities,
745
+ pullRequest: currentPullRequest, resource: { kind: 'pull-request', number: prNumber }, operations, resources: resourcesState,
746
+ evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value },
747
+ error: { ...reread.error, code: 'IN_LABEL_SYNC_PARTIAL', message: `In-label synchronization is partial or unknown: ${reread.error.message}` }
748
+ });
749
+ const after = reread.value.labels;
750
+ const state = resourcesState.find((resource) => resource.kind === item.kind && resource.number === item.number);
751
+ state.after = after;
752
+ state.effect = after.filter((label) => label.startsWith('in:')).sort().join('\0') === target.join('\0') ? 'applied' : 'unknown';
753
+ if (state.effect === 'unknown')
754
+ return result('blocked', null, issueNumber, prNumber, {
755
+ changed: true, platform: context.platform, capabilities: context.capabilities,
756
+ pullRequest: currentPullRequest, resource: { kind: 'pull-request', number: prNumber }, operations, resources: resourcesState,
757
+ evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value },
758
+ error: { code: 'IN_LABEL_SYNC_PARTIAL', message: `${item.kind} #${item.number} did not converge to the expected in: labels`, retryable: true }
759
+ });
760
+ if (item.kind === 'pull-request')
761
+ currentPullRequest = reread.value;
762
+ }
763
+ return result(association.value.length !== 1 ? 'degraded' : successfulWrites > 0 ? 'applied' : 'no-op', null, issueNumber, prNumber, {
764
+ changed: successfulWrites > 0, platform: context.platform, capabilities: context.capabilities,
765
+ pullRequest: currentPullRequest, resource: { kind: 'pull-request', number: prNumber },
766
+ operations: operations.map((operation) => operation.status === 'planned' ? { ...operation, status: 'applied' } : operation),
767
+ resources: resourcesState, evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value }, error: null
598
768
  });
599
769
  }
600
770
  function appendActivity(content, line) {
@@ -603,9 +773,8 @@ function appendActivity(content, line) {
603
773
  }
604
774
  function deliveryIdentity(pullRequest) {
605
775
  return {
776
+ resource: pullRequestIdentity(pullRequest),
606
777
  repository: pullRequest.repository,
607
- number: pullRequest.number,
608
- nodeId: pullRequest.nodeId,
609
778
  url: pullRequest.url,
610
779
  head: { ...pullRequest.head },
611
780
  base: { ...pullRequest.base }
@@ -614,11 +783,11 @@ function deliveryIdentity(pullRequest) {
614
783
  function factTimestamp(timestamp) {
615
784
  return new Date(timestamp.replace(' ', 'T')).toISOString();
616
785
  }
617
- function boundFactFor(pullRequest, source, issueNumber, verifiedAt) {
786
+ function boundFactFor(pullRequest, source, issue, verifiedAt) {
618
787
  return buildBoundFact({
619
788
  identity: deliveryIdentity(pullRequest),
620
789
  source,
621
- issueNumber,
790
+ ...(typeof issue === 'object' ? { issueIdentity: issue } : { issueNumber: issue }),
622
791
  verifiedAt: factTimestamp(verifiedAt),
623
792
  remoteState: pullRequest.state,
624
793
  mergedAt: pullRequest.state === 'closed' ? pullRequest.mergedAt : null,
@@ -645,11 +814,11 @@ function bindIdentity(base, pullRequest, agent, source, dryRun = false, expected
645
814
  });
646
815
  if (!identity.ok)
647
816
  return { status: 'failed', error: identity.error };
648
- if (base.prNumber && base.prNumber !== pullRequest.number)
817
+ if (base.prIdentity && !resourceIdentityEquals(base.prIdentity, pullRequestIdentity(pullRequest)))
649
818
  return { status: 'failed', error: {
650
- code: 'PR_BIND_CONFLICT', message: `Task is already bound to PR #${base.prNumber}`
819
+ code: 'PR_BIND_CONFLICT', message: 'Task is already bound to a different pull request identity'
651
820
  } };
652
- if (base.prNumber === pullRequest.number && base.fact?.state === 'bound') {
821
+ if (base.prIdentity && resourceIdentityEquals(base.prIdentity, pullRequestIdentity(pullRequest)) && base.fact?.state === 'bound') {
653
822
  if (JSON.stringify(base.fact.identity) !== JSON.stringify(deliveryIdentity(pullRequest)))
654
823
  return { status: 'failed', error: {
655
824
  code: 'PR_BIND_IDENTITY_MISMATCH', message: `Task binding for PR #${pullRequest.number} does not match the fetched pull request identity`
@@ -658,7 +827,7 @@ function bindIdentity(base, pullRequest, agent, source, dryRun = false, expected
658
827
  }
659
828
  const current = fs.readFileSync(base.resolved.taskMdPath, 'utf8');
660
829
  const metadata = captureTaskWriteMetadata();
661
- const fact = boundFactFor(pullRequest, source, base.issueNumber, metadata.timestamp);
830
+ const fact = boundFactFor(pullRequest, source, taskIssueIdentity(base.frontmatter), metadata.timestamp);
662
831
  const factMutation = factFrontmatterMutation(fact);
663
832
  return writeTask({
664
833
  taskRef: base.resolved.taskId,
@@ -670,64 +839,60 @@ function bindIdentity(base, pullRequest, agent, source, dryRun = false, expected
670
839
  ]
671
840
  }, { repoRoot: base.resolved.repoRoot, metadataProvider: () => metadata });
672
841
  }
673
- function bindPlatformPullRequest(taskRef, options) {
674
- const base = resolvedContext(taskRef, options);
842
+ async function bindPlatformPullRequest(taskRef, options) {
843
+ const base = await resolvedContext(taskRef, options);
675
844
  if (!base.ok)
676
845
  return base.output;
677
846
  if (!base.fact)
678
847
  return result('failed', base.resolved.taskId, base.issueNumber, null, {
679
848
  error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
680
849
  });
681
- if (!Number.isInteger(options.pr) || options.pr <= 0)
682
- return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
683
- error: { code: 'PR_NUMBER_INVALID', message: 'PR number must be positive', retryable: false }
684
- });
685
- if (base.prNumber && base.prNumber !== options.pr)
850
+ let prIdentity;
851
+ try {
852
+ prIdentity = providerResourceToken(base.provider, 'pull-request', String(options.pr));
853
+ }
854
+ catch (error) {
855
+ return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, { error: { code: 'PLATFORM_IDENTITY_TOKEN_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false } });
856
+ }
857
+ const requestedNumber = resourceIdentityNumber(prIdentity);
858
+ if (base.prIdentity && !resourceIdentityEquals(base.prIdentity, prIdentity))
686
859
  return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
687
- error: { code: 'PR_BIND_CONFLICT', message: `Task is already bound to PR #${base.prNumber}`, retryable: false }
860
+ error: { code: 'PR_BIND_CONFLICT', message: 'Task is already bound to a different pull request identity', retryable: false }
688
861
  });
689
- const fetched = inspectGitHubPullRequest(base.client, base.context.platform.repository, options.pr, base.resolved.repoRoot);
690
- if (!fetched.ok)
691
- return result(fetched.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, { error: fetched.error });
692
- const initialIdentity = validateWriterIdentity(base, fetched.value, {
862
+ const inspected = await inspectExternalPullRequest(base, prIdentity);
863
+ if (inspected.status === 'failed' || inspected.status === 'blocked' || !inspected.pullRequest)
864
+ return inspected;
865
+ const initialIdentity = validateWriterIdentity(base, inspected.pullRequest, {
693
866
  expectedHead: String(base.frontmatter.branch || ''),
694
867
  errorCode: 'PR_BIND_IDENTITY_MISMATCH'
695
868
  });
696
869
  if (!initialIdentity.ok)
697
- return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
698
- pullRequest: fetched.value, error: initialIdentity.error
699
- });
700
- const rechecked = inspectGitHubPullRequest(base.client, base.context.platform.repository, options.pr, base.resolved.repoRoot);
701
- if (!rechecked.ok || !rechecked.value)
702
- return result(rechecked.ok ? 'failed' : rechecked.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
703
- pullRequest: fetched.value,
704
- error: rechecked.ok ? { code: 'PR_BIND_RECHECK_FAILED', message: 'Pull request recheck returned no identity', retryable: false } : rechecked.error
705
- });
706
- if (!sameDeliveryIdentity(fetched.value, rechecked.value))
707
- return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
708
- pullRequest: rechecked.value,
709
- error: { code: 'PR_BIND_IDENTITY_MISMATCH', message: 'Pull request identity changed before task binding', retryable: false }
870
+ return result('failed', base.resolved.taskId, base.issueNumber, requestedNumber, {
871
+ pullRequest: inspected.pullRequest, error: initialIdentity.error
710
872
  });
711
- const written = bindIdentity(base, rechecked.value, options.agent, 'explicit-bind', options.dryRun);
873
+ const written = bindIdentity(base, inspected.pullRequest, options.agent, 'explicit-bind', options.dryRun);
712
874
  if (written.status === 'failed')
713
- return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
714
- pullRequest: fetched.value, error: { code: written.error.code, message: written.error.message, retryable: false }
875
+ return result('failed', base.resolved.taskId, base.issueNumber, requestedNumber, {
876
+ pullRequest: inspected.pullRequest, error: { code: written.error.code, message: written.error.message, retryable: false }
715
877
  });
716
- return result(options.dryRun ? 'planned' : written.status === 'no-op' ? 'no-op' : 'applied', base.resolved.taskId, base.issueNumber, options.dryRun ? base.prNumber : rechecked.value.number, {
878
+ return result(options.dryRun ? 'planned' : written.status === 'no-op' ? 'no-op' : 'applied', base.resolved.taskId, base.issueNumber, options.dryRun ? base.prNumber : inspected.pullRequest.number, {
717
879
  changed: !options.dryRun && written.status === 'applied', platform: base.context.platform, capabilities: base.context.capabilities,
718
- resource: { kind: 'pull-request', number: rechecked.value.number }, pullRequest: rechecked.value,
880
+ resource: { kind: 'pull-request', number: inspected.pullRequest.number, identity: pullRequestIdentity(inspected.pullRequest) }, pullRequest: inspected.pullRequest,
719
881
  operations: [{ name: 'task:bind-pr', status: options.dryRun ? 'planned' : written.status === 'no-op' ? 'no-op' : 'applied', reasonCode: null }], error: null
720
882
  });
721
883
  }
722
- function externalEvidenceNote(issueNumber, source, pullRequest) {
884
+ function externalEvidenceNote(issueIdentity, source, pullRequest) {
723
885
  return [
724
886
  `authorization=${source}`,
725
- externalIdentityEvidenceNote(issueNumber, pullRequest)
887
+ externalIdentityEvidenceNote(issueIdentity, pullRequest)
726
888
  ].join('; ');
727
889
  }
728
- function externalIdentityEvidenceNote(issueNumber, pullRequest) {
890
+ function externalIdentityEvidenceNote(issueIdentity, pullRequest) {
891
+ const issueLabel = issueIdentity
892
+ ? issueIdentity.kind === 'number' ? `#${issueIdentity.value}` : `${issueIdentity.kind}:${issueIdentity.value}`
893
+ : 'unknown';
729
894
  return [
730
- `issue=#${issueNumber}`,
895
+ `issue=${issueLabel}`,
731
896
  `pr=#${pullRequest.number}`,
732
897
  `url=${pullRequest.url}`,
733
898
  `base=${pullRequest.base.repository}:${pullRequest.base.ref}@${pullRequest.base.sha}`,
@@ -736,8 +901,8 @@ function externalIdentityEvidenceNote(issueNumber, pullRequest) {
736
901
  `mergeCommitSha=${pullRequest.mergeCommitSha}`
737
902
  ].join('; ');
738
903
  }
739
- function resolveExternalPullRequest(taskRef, options) {
740
- const base = resolvedContext(taskRef, options);
904
+ async function resolveExternalPullRequest(taskRef, options) {
905
+ const base = await resolvedContext(taskRef, options);
741
906
  if (!base.ok)
742
907
  return externalResult(base.output);
743
908
  if (!base.fact)
@@ -765,25 +930,40 @@ function resolveExternalPullRequest(taskRef, options) {
765
930
  error: null
766
931
  }), { mode: 'normal' });
767
932
  }
768
- if (!base.issueNumber)
933
+ if (!base.issueIdentity)
769
934
  return externalResult(result('failed', base.resolved.taskId, null, base.prNumber, {
770
935
  platform: base.context.platform,
771
936
  capabilities: base.context.capabilities,
772
937
  error: { code: 'EXTERNAL_DELIVERY_ISSUE_REQUIRED', message: 'External delivery requires a valid issue_number', retryable: false }
773
938
  }));
774
- const inspected = inspectPlatformIssueClosingChangeRequests(base.context.platform.type, {
775
- cwd: base.resolved.repoRoot,
776
- repository: base.context.platform.repository,
777
- issueNumber: base.issueNumber,
778
- client: base.client
779
- });
939
+ const inspected = base.provider.changeRequests?.listClosing
940
+ ? await base.provider.changeRequests.listClosing({
941
+ context: providerOperationContext(base.loadedContext),
942
+ issue: base.issueIdentity
943
+ })
944
+ : unsupportedProviderOperation(base.provider, 'changeRequests.listClosing');
780
945
  if (!inspected.ok || !inspected.value) {
781
- const error = inspected.error || { code: 'PR_IDENTITY_INVALID', message: 'Closing PR inspection returned no candidates', retryable: false };
946
+ const error = 'error' in inspected && inspected.error
947
+ ? inspected.error
948
+ : { code: 'PR_IDENTITY_INVALID', message: 'Closing PR inspection returned no candidates', retryable: false };
782
949
  return externalResult(result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
783
950
  platform: base.context.platform, capabilities: base.context.capabilities, error
784
951
  }));
785
952
  }
786
- const selected = selectExternalPullRequest(inspected.value, base.context.platform.repository, base.prNumber, options.pr ?? null);
953
+ const candidates = inspected.value.map((item) => normalizeProviderPullRequest(item, base.context.platform.repository, item.number || 0));
954
+ let explicitPrIdentity = null;
955
+ if (options.pr !== undefined) {
956
+ try {
957
+ explicitPrIdentity = providerResourceToken(base.provider, 'pull-request', String(options.pr));
958
+ }
959
+ catch (error) {
960
+ return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
961
+ platform: base.context.platform, capabilities: base.context.capabilities,
962
+ error: { code: 'PLATFORM_IDENTITY_TOKEN_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
963
+ }));
964
+ }
965
+ }
966
+ const selected = selectExternalPullRequest(candidates, base.context.platform.repository, base.prIdentity, explicitPrIdentity);
787
967
  if (selected.status === 'normal')
788
968
  return externalResult(result('no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
789
969
  platform: base.context.platform,
@@ -803,7 +983,14 @@ function resolveExternalPullRequest(taskRef, options) {
803
983
  platform: base.context.platform, capabilities: base.context.capabilities,
804
984
  pullRequest: selected.selected, error: initialIdentity.error
805
985
  }), { candidates: selected.candidates, eligible: selected.eligible, selected: selected.selected });
806
- const rechecked = inspectGitHubPullRequest(base.client, base.context.platform.repository, selected.selected.number, base.resolved.repoRoot);
986
+ const rechecked = base.provider.changeRequests?.inspect
987
+ ? await base.provider.changeRequests.inspect({
988
+ context: providerOperationContext(base.loadedContext),
989
+ target: pullRequestIdentity(selected.selected)
990
+ }).then((response) => response.ok
991
+ ? { ok: true, value: normalizeProviderPullRequest(response.value, base.context.platform.repository, selected.selected.number, selected.selected) }
992
+ : response)
993
+ : unsupportedProviderOperation(base.provider, 'changeRequests.inspect');
807
994
  if (!rechecked.ok || !rechecked.value)
808
995
  return externalResult(result(rechecked.ok ? 'failed' : rechecked.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
809
996
  platform: base.context.platform, capabilities: base.context.capabilities,
@@ -829,8 +1016,8 @@ function resolveExternalPullRequest(taskRef, options) {
829
1016
  pullRequest: rechecked.value, error: finalEvidence.error
830
1017
  }), { candidates: selected.candidates, eligible: selected.eligible, selected: rechecked.value });
831
1018
  const selectedPullRequest = rechecked.value;
832
- const note = externalEvidenceNote(base.issueNumber, selected.source, selectedPullRequest);
833
- const identityNote = externalIdentityEvidenceNote(base.issueNumber, selectedPullRequest);
1019
+ const note = externalEvidenceNote(base.issueIdentity, selected.source, selectedPullRequest);
1020
+ const identityNote = externalIdentityEvidenceNote(base.issueIdentity, selectedPullRequest);
834
1021
  const activityEntry = `**Bind External PR** by ${options.agent} — ${note}`;
835
1022
  const auditLines = base.content.split(/\r?\n/).filter((line) => line.includes('**Bind External PR**'));
836
1023
  const alreadyAudited = auditLines.some((line) => line.endsWith(identityNote));
@@ -841,14 +1028,14 @@ function resolveExternalPullRequest(taskRef, options) {
841
1028
  pullRequest: selectedPullRequest,
842
1029
  error: { code: 'PR_BIND_CONFLICT', message: 'Existing external PR audit evidence conflicts with the selected identity', retryable: false }
843
1030
  }), { candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
844
- if (alreadyAudited && base.prNumber !== selectedPullRequest.number)
1031
+ if (alreadyAudited && base.prIdentity && !resourceIdentityEquals(base.prIdentity, pullRequestIdentity(selectedPullRequest)))
845
1032
  return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
846
1033
  platform: base.context.platform,
847
1034
  capabilities: base.context.capabilities,
848
1035
  pullRequest: selectedPullRequest,
849
1036
  error: { code: 'PR_BIND_CONFLICT', message: 'External PR audit evidence does not match the task binding', retryable: false }
850
1037
  }), { candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
851
- if (base.prNumber === selectedPullRequest.number && alreadyAudited) {
1038
+ if (base.prIdentity && resourceIdentityEquals(base.prIdentity, pullRequestIdentity(selectedPullRequest)) && alreadyAudited) {
852
1039
  return externalResult(result('no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
853
1040
  platform: base.context.platform,
854
1041
  capabilities: base.context.capabilities,
@@ -886,18 +1073,42 @@ function resolveExternalPullRequest(taskRef, options) {
886
1073
  error: null
887
1074
  }), { mode: 'external', authorization: selected.source, candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
888
1075
  }
889
- function createPlatformPullRequestUnlocked(taskRef, options) {
890
- const base = resolvedContext(taskRef, options);
1076
+ async function createPlatformPullRequest(taskRef, options) {
1077
+ const base = await resolvedContext(taskRef, options);
891
1078
  if (!base.ok)
892
1079
  return withCreation(base.output, PRECONDITION_NOT_CREATED);
1080
+ if (!options.base || !options.head || !options.title.trim() || !options.body.trim()) {
1081
+ return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1082
+ error: { code: 'PR_PAYLOAD_INVALID', message: 'base, head, title and body are required', retryable: false }
1083
+ }), PRECONDITION_NOT_CREATED);
1084
+ }
1085
+ try {
1086
+ return withTaskExecutionLock(base.resolved.repoRoot, base.resolved.taskId, 'platform-pr.create', () => createExternalPullRequest(taskRef, options, base));
1087
+ }
1088
+ catch (error) {
1089
+ if (error instanceof TaskExecutionLockError) {
1090
+ return withCreation(result('blocked', base.resolved.taskId, base.issueNumber, null, {
1091
+ platform: base.context.platform,
1092
+ capabilities: base.context.capabilities,
1093
+ error: { code: error.code, message: error.message, retryable: true }
1094
+ }), PRECONDITION_NOT_CREATED);
1095
+ }
1096
+ return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1097
+ platform: base.context.platform,
1098
+ capabilities: base.context.capabilities,
1099
+ error: {
1100
+ code: 'PR_CREATE_FAILED',
1101
+ message: error instanceof Error ? error.message : String(error),
1102
+ retryable: false
1103
+ }
1104
+ }), PRECONDITION_NOT_CREATED);
1105
+ }
1106
+ }
1107
+ async function createExternalPullRequest(taskRef, options, base) {
893
1108
  if (!base.fact)
894
1109
  return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
895
1110
  error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
896
1111
  }), PRECONDITION_NOT_CREATED);
897
- if (!options.base || !options.head || !options.title.trim() || !options.body.trim())
898
- return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
899
- error: { code: 'PR_PAYLOAD_INVALID', message: 'base, head, title and body are required', retryable: false }
900
- }), PRECONDITION_NOT_CREATED);
901
1112
  const target = taskDeliveryTarget(base);
902
1113
  if (!target.ok)
903
1114
  return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
@@ -905,194 +1116,132 @@ function createPlatformPullRequestUnlocked(taskRef, options) {
905
1116
  }), PRECONDITION_NOT_CREATED);
906
1117
  if (options.base !== target.value.baseRef)
907
1118
  return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
908
- platform: base.context.platform, capabilities: base.context.capabilities,
909
- error: { code: 'PR_DELIVERY_TARGET_MISMATCH', message: `Pull request base '${options.base}' does not match delivery target '${target.value.baseRef}'`, retryable: false }
910
- }), PRECONDITION_NOT_CREATED);
911
- if (base.prNumber) {
912
- const headCheck = verifyCreateHead(base, options.head);
913
- if (!headCheck.ok)
914
- return withCreation(result(headCheck.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
915
- platform: base.context.platform,
916
- capabilities: base.context.capabilities,
917
- error: headCheck.error
918
- }), PRECONDITION_NOT_CREATED);
919
- const inspected = inspectPlatformPullRequest(taskRef, options);
920
- const identity = validateBoundPullRequest(base, inspected.pullRequest, options.head, options.base, headCheck.value);
921
- if (!identity.ok)
922
- return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
923
- platform: base.context.platform,
924
- capabilities: base.context.capabilities,
925
- pullRequest: inspected.pullRequest,
926
- error: identity.error
927
- }), PRECONDITION_NOT_CREATED);
928
- return withCreation({ ...inspected, result: 'no_op' }, { kind: 'no-op', createdByCurrentOperation: false });
929
- }
930
- const headCheck = verifyCreateHead(base, options.head);
931
- if (!headCheck.ok)
932
- return withCreation(result(headCheck.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, null, {
933
1119
  platform: base.context.platform,
934
1120
  capabilities: base.context.capabilities,
935
- error: headCheck.error
936
- }), PRECONDITION_NOT_CREATED);
937
- const expectedHeadSha = headCheck.value;
938
- const located = locatePullRequest(base, options.head, options.base, expectedHeadSha);
939
- if (!located.ok && located.error.code === 'PR_IDENTITY_AMBIGUOUS')
940
- return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
941
- platform: base.context.platform, capabilities: base.context.capabilities, error: located.error
942
- }), PRECONDITION_NOT_CREATED);
943
- if (!located.ok && located.error.code === 'PR_HEAD_SHA_MISMATCH')
944
- return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
945
- platform: base.context.platform, capabilities: base.context.capabilities, error: located.error
946
- }), PRECONDITION_NOT_CREATED);
947
- if (!located.ok && located.error.code !== 'PR_NOT_FOUND')
948
- return withCreation(result(located.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, null, {
949
- platform: base.context.platform, capabilities: base.context.capabilities, error: located.error
1121
+ error: { code: 'PR_DELIVERY_TARGET_MISMATCH', message: `Pull request base '${options.base}' does not match delivery target '${target.value.baseRef}'`, retryable: false }
950
1122
  }), PRECONDITION_NOT_CREATED);
1123
+ let verifiedHeadSha;
1124
+ if (base.provider.changeRequests?.verifyHead) {
1125
+ const verified = await base.provider.changeRequests.verifyHead({
1126
+ context: providerOperationContext(base.loadedContext),
1127
+ head: options.head
1128
+ });
1129
+ if (!verified.ok)
1130
+ return withCreation(providerPullRequestError(base, verified.error, base.prNumber), PRECONDITION_NOT_CREATED);
1131
+ verifiedHeadSha = verified.value.sha;
1132
+ }
1133
+ if (base.prIdentity) {
1134
+ const inspected = await inspectExternalPullRequest(base, base.prIdentity);
1135
+ return inspected.status === 'failed' || inspected.status === 'blocked'
1136
+ ? withCreation(inspected, PRECONDITION_NOT_CREATED)
1137
+ : withCreation({ ...inspected, result: 'no_op' }, { kind: 'no-op', createdByCurrentOperation: false });
1138
+ }
951
1139
  if (options.dryRun)
952
1140
  return withCreation(result('planned', base.resolved.taskId, base.issueNumber, null, {
953
- platform: base.context.platform, capabilities: base.context.capabilities,
954
- operations: [{ name: located.ok ? 'pr:reuse' : 'pr:create', status: 'planned', reasonCode: null }],
955
- result: located.ok ? 'pr_reused' : 'pr_created', error: null
956
- }), { kind: 'planned', action: located.ok ? 'reuse' : 'create', createdByCurrentOperation: false });
1141
+ platform: base.context.platform,
1142
+ capabilities: base.context.capabilities,
1143
+ operations: [{ name: 'pr:create', status: 'planned', reasonCode: null }],
1144
+ result: 'pr_created',
1145
+ error: null
1146
+ }), { kind: 'planned', action: 'create', createdByCurrentOperation: false });
957
1147
  const started = writeCreateStarted(base, options.agent);
958
1148
  if (started.status === 'failed')
959
1149
  return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
960
1150
  error: { code: started.error.code, message: started.error.message, retryable: false }
961
1151
  }), PRECONDITION_NOT_CREATED);
962
- let pullRequest = located.ok ? located.value : null;
963
- let created = false;
964
- if (!pullRequest) {
965
- const repository = base.context.platform.repository;
966
- if (options.phase)
967
- options.phase.value = 'post-dispatched';
968
- const response = base.client.json(['api', `repos/${repository}/pulls`, '-X', 'POST', '--input', '-'], {
969
- cwd: base.resolved.repoRoot,
970
- method: 'POST',
971
- input: JSON.stringify({ title: options.title, body: options.body, head: options.head, base: options.base, draft: Boolean(options.draft) })
972
- });
973
- if (response.ok) {
974
- // The remote POST is accepted before any normalization, recheck, or task write.
975
- // From this point on the current call has created a remote resource even if
976
- // local post-processing later fails.
977
- created = true;
978
- if (options.phase)
979
- options.phase.value = 'post-accepted';
980
- pullRequest = normalizePullRequest(response.value, repository);
981
- }
982
- else if (response.error.retryable) {
983
- return withCreation(result('blocked', base.resolved.taskId, base.issueNumber, null, {
984
- platform: base.context.platform, capabilities: base.context.capabilities,
985
- operations: [{ name: 'pr:create', status: 'failed', reasonCode: 'PR_CREATE_OUTCOME_UNKNOWN' }],
986
- error: { code: 'PR_CREATE_OUTCOME_UNKNOWN', message: response.error.message, retryable: true }
987
- }), { kind: 'unknown', errorCode: 'PR_CREATE_OUTCOME_UNKNOWN' });
988
- }
989
- else
990
- return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
991
- platform: base.context.platform, capabilities: base.context.capabilities,
992
- operations: [{ name: 'pr:create', status: 'failed', reasonCode: response.error.code }], error: response.error
993
- }), { kind: 'not-created', reason: 'post-rejected', createdByCurrentOperation: false });
994
- if (!pullRequest)
995
- return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
996
- error: { code: 'PR_CREATE_RESPONSE_INVALID', message: 'PR create response lacks validated identity', retryable: false }
997
- }), { kind: 'created', createdByCurrentOperation: true });
998
- if (pullRequest.head.sha !== expectedHeadSha)
999
- return withCreation(result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1000
- platform: base.context.platform, capabilities: base.context.capabilities,
1001
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest,
1002
- error: { code: 'PR_HEAD_SHA_MISMATCH', message: 'Created pull request head does not match the expected local HEAD', retryable: false }
1003
- }), { kind: 'created', createdByCurrentOperation: true });
1004
- }
1005
- const bindHeadCheck = verifyCreateHead(base, options.head);
1006
- if (!bindHeadCheck.ok || bindHeadCheck.value !== expectedHeadSha) {
1007
- const error = bindHeadCheck.ok
1008
- ? { code: 'PR_REMOTE_HEAD_MISMATCH', message: 'Remote head changed before task binding', retryable: false }
1009
- : bindHeadCheck.error;
1010
- return withCreation(result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1011
- platform: base.context.platform, capabilities: base.context.capabilities,
1012
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest, error
1013
- }), created ? { kind: 'created', createdByCurrentOperation: true } : { kind: 'not-created', reason: 'precondition-failed', createdByCurrentOperation: false });
1152
+ const created = base.provider.changeRequests?.create
1153
+ ? await base.provider.changeRequests.create({
1154
+ context: providerOperationContext(base.loadedContext),
1155
+ base: options.base,
1156
+ head: options.head,
1157
+ title: options.title,
1158
+ body: options.body,
1159
+ draft: Boolean(options.draft),
1160
+ mutation: { idempotencyKey: `pull-request:create:${base.resolved.taskId}` }
1161
+ })
1162
+ : unsupportedProviderOperation(base.provider, 'changeRequests.create');
1163
+ if (!created.ok)
1164
+ return withCreation(providerPullRequestError(base, created.error, null), { kind: 'unknown', errorCode: 'PR_CREATE_OUTCOME_UNKNOWN' });
1165
+ const remoteId = String(created.value.remoteId || '');
1166
+ let createdIdentity;
1167
+ try {
1168
+ createdIdentity = providerResourceIdentity(base.provider, 'pull-request', remoteId);
1014
1169
  }
1015
- const beforeBind = inspectGitHubPullRequest(base.client, base.context.platform.repository, pullRequest.number, base.resolved.repoRoot);
1016
- if (!beforeBind.ok || !beforeBind.value) {
1017
- const error = beforeBind.ok
1018
- ? { code: 'PR_BIND_RECHECK_FAILED', message: 'Pull request recheck returned no identity', retryable: false }
1019
- : beforeBind.error;
1020
- return withCreation(result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1170
+ catch (error) {
1171
+ return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1021
1172
  platform: base.context.platform, capabilities: base.context.capabilities,
1022
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest, error
1023
- }), created ? { kind: 'created', createdByCurrentOperation: true } : { kind: 'not-created', reason: 'precondition-failed', createdByCurrentOperation: false });
1173
+ error: { code: 'PR_CREATE_RESPONSE_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
1174
+ }), { kind: 'created', createdByCurrentOperation: true });
1024
1175
  }
1025
- if (beforeBind.value.head.sha !== expectedHeadSha
1026
- || beforeBind.value.head.ref !== expectedHead(base.context.platform.repository, options.head).ref
1027
- || beforeBind.value.base.ref !== options.base
1028
- || beforeBind.value.base.repository !== base.context.platform.repository)
1029
- return withCreation(result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1030
- platform: base.context.platform, capabilities: base.context.capabilities,
1031
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest: beforeBind.value,
1032
- error: { code: 'PR_BIND_IDENTITY_MISMATCH', message: 'Pull request identity changed before task binding', retryable: false }
1033
- }), created ? { kind: 'created', createdByCurrentOperation: true } : { kind: 'not-created', reason: 'precondition-failed', createdByCurrentOperation: false });
1034
- pullRequest = beforeBind.value;
1035
- const refreshed = resolvedContext(taskRef, options);
1036
- if (!refreshed.ok)
1176
+ const number = resourceIdentityNumber(createdIdentity);
1177
+ const fallbackNumber = number ?? 0;
1178
+ const fallback = normalizeProviderPullRequest({
1179
+ id: remoteId,
1180
+ identity: createdIdentity,
1181
+ number: number || undefined,
1182
+ state: 'open',
1183
+ title: options.title,
1184
+ body: options.body,
1185
+ headSha: '',
1186
+ baseSha: '',
1187
+ head: { repository: base.context.platform.repository, ref: options.head, sha: '' },
1188
+ base: { repository: base.context.platform.repository, ref: options.base, sha: '' },
1189
+ displayUrl: `${base.provider.type}:${remoteId}`
1190
+ }, base.context.platform.repository, fallbackNumber);
1191
+ const inspected = base.provider.changeRequests?.inspect
1192
+ ? await base.provider.changeRequests.inspect({
1193
+ context: providerOperationContext(base.loadedContext),
1194
+ target: createdIdentity
1195
+ })
1196
+ : unsupportedProviderOperation(base.provider, 'changeRequests.inspect');
1197
+ if (!inspected.ok)
1198
+ return withCreation(providerPullRequestError(base, inspected.error, number), { kind: 'created', createdByCurrentOperation: true });
1199
+ const pullRequest = normalizeProviderPullRequest(inspected.value, base.context.platform.repository, inspected.value.number ?? fallback.number, fallback);
1200
+ if (!pullRequest.nodeId || !isUsableResourceIdentity(pullRequestIdentity(pullRequest)))
1037
1201
  return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1038
- pullRequest, error: { code: 'PR_CREATED_BIND_FAILED', message: pullRequest.url, retryable: false }
1039
- }), created ? { kind: 'created', createdByCurrentOperation: true } : { kind: 'not-created', reason: 'precondition-failed', createdByCurrentOperation: false });
1040
- const bound = bindIdentity(refreshed, pullRequest, options.agent, created ? 'created' : 'reused', false, options.head, expectedHeadSha);
1202
+ platform: base.context.platform,
1203
+ capabilities: base.context.capabilities,
1204
+ error: { code: 'PR_CREATE_RESPONSE_INVALID', message: 'Provider create response lacks validated identity', retryable: false }
1205
+ }), { kind: 'created', createdByCurrentOperation: true });
1206
+ const identity = validateWriterIdentity(base, pullRequest, {
1207
+ expectedHead: options.head,
1208
+ expectedHeadSha: verifiedHeadSha,
1209
+ errorCode: 'PR_BIND_IDENTITY_MISMATCH'
1210
+ });
1211
+ if (!identity.ok)
1212
+ return withCreation(result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1213
+ platform: base.context.platform,
1214
+ capabilities: base.context.capabilities,
1215
+ resource: { kind: 'pull-request', number: pullRequest.number },
1216
+ pullRequest,
1217
+ error: identity.error
1218
+ }), { kind: 'created', createdByCurrentOperation: true });
1219
+ const bound = bindIdentity(base, pullRequest, options.agent, 'created', false, options.head);
1041
1220
  if (bound.status === 'failed')
1042
- return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1043
- platform: base.context.platform, capabilities: base.context.capabilities,
1044
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest,
1045
- operations: [{ name: created ? 'pr:create' : 'pr:reuse', status: 'applied', reasonCode: null }, { name: 'task:bind-pr', status: 'failed', reasonCode: bound.error.code }],
1046
- error: { code: 'PR_CREATED_BIND_FAILED', message: `${pullRequest.url}: ${bound.error.message}`, retryable: false }
1047
- }), created ? { kind: 'created', createdByCurrentOperation: true } : { kind: 'reused', createdByCurrentOperation: false });
1048
- return withCreation(result('applied', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1049
- changed: true, platform: base.context.platform, capabilities: base.context.capabilities,
1050
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest,
1051
- operations: [{ name: created ? 'pr:create' : 'pr:reuse', status: created ? 'applied' : 'no-op', reasonCode: null }, { name: 'task:bind-pr', status: 'applied', reasonCode: null }],
1052
- result: created ? 'pr_created' : 'pr_reused', error: null
1053
- }), created ? { kind: 'created', createdByCurrentOperation: true } : { kind: 'reused', createdByCurrentOperation: false });
1054
- }
1055
- function createPlatformPullRequest(taskRef, options) {
1056
- const base = resolvedContext(taskRef, options);
1057
- if (!base.ok)
1058
- return withCreation(base.output, PRECONDITION_NOT_CREATED);
1059
- if (!options.base || !options.head || !options.title.trim() || !options.body.trim()) {
1060
- return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1061
- error: { code: 'PR_PAYLOAD_INVALID', message: 'base, head, title and body are required', retryable: false }
1062
- }), PRECONDITION_NOT_CREATED);
1063
- }
1064
- const phase = { value: 'before-post' };
1065
- try {
1066
- return withTaskExecutionLock(base.resolved.repoRoot, base.resolved.taskId, 'platform-pr.create', () => createPlatformPullRequestUnlocked(taskRef, { ...options, phase }));
1067
- }
1068
- catch (error) {
1069
- if (error instanceof TaskExecutionLockError) {
1070
- return withCreation(result('blocked', base.resolved.taskId, base.issueNumber, null, {
1071
- platform: base.context.platform,
1072
- capabilities: base.context.capabilities,
1073
- error: { code: error.code, message: error.message, retryable: true }
1074
- }), PRECONDITION_NOT_CREATED);
1075
- }
1076
- const creation = phase.value === 'post-accepted'
1077
- ? { kind: 'created', createdByCurrentOperation: true }
1078
- : phase.value === 'post-dispatched'
1079
- ? { kind: 'unknown', errorCode: 'PR_CREATE_OUTCOME_UNKNOWN' }
1080
- : PRECONDITION_NOT_CREATED;
1081
- return withCreation(result(phase.value === 'post-dispatched' ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, null, {
1221
+ return withCreation(result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1082
1222
  platform: base.context.platform,
1083
1223
  capabilities: base.context.capabilities,
1084
- error: {
1085
- code: phase.value === 'post-dispatched' ? 'PR_CREATE_OUTCOME_UNKNOWN' : 'PR_CREATE_FAILED',
1086
- message: error instanceof Error ? error.message : String(error),
1087
- retryable: phase.value === 'post-dispatched'
1088
- }
1089
- }), creation);
1090
- }
1224
+ resource: { kind: 'pull-request', number: pullRequest.number },
1225
+ pullRequest,
1226
+ error: { code: 'PR_CREATED_BIND_FAILED', message: `${pullRequest.url}: ${bound.error.message}`, retryable: false }
1227
+ }), { kind: 'created', createdByCurrentOperation: true });
1228
+ return withCreation(result('applied', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1229
+ changed: true,
1230
+ platform: base.context.platform,
1231
+ capabilities: base.context.capabilities,
1232
+ resource: { kind: 'pull-request', number: pullRequest.number },
1233
+ pullRequest,
1234
+ operations: [{ name: 'pr:create', status: 'applied', reasonCode: null }, { name: 'task:bind-pr', status: 'applied', reasonCode: null }],
1235
+ result: 'pr_created',
1236
+ error: null
1237
+ }), { kind: 'created', createdByCurrentOperation: true });
1091
1238
  }
1092
- function syncPlatformPullRequest(taskRef, options) {
1239
+ async function syncPlatformPullRequest(taskRef, options) {
1093
1240
  const warningResult = warningResultForPrimary(options.primaryResult);
1094
1241
  const softenFailure = (output) => {
1242
+ const authorityError = output.error?.code.startsWith('IN_LABEL_SYNC') || output.error?.code.startsWith('PR_IDENTITY');
1095
1243
  const warning = output.error && output.task.prNumber !== null
1244
+ && !authorityError
1096
1245
  && !['PR_NOT_LINKED', 'PR_BIND_CONFLICT'].includes(output.error.code)
1097
1246
  ? {
1098
1247
  code: output.error.code,
@@ -1107,80 +1256,169 @@ function syncPlatformPullRequest(taskRef, options) {
1107
1256
  ? { ...output, status: 'applied', changed: false, error: null, result: warningResult, warnings: mergeOperationWarnings([warning]) }
1108
1257
  : output;
1109
1258
  };
1110
- const base = resolvedContext(taskRef, options);
1259
+ const base = await resolvedContext(taskRef, options);
1111
1260
  if (!base.ok)
1112
1261
  return softenFailure(base.output);
1113
1262
  if (!base.fact)
1114
1263
  return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, null, {
1115
1264
  error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
1116
1265
  }));
1117
- if (!base.prNumber)
1266
+ if (!base.prIdentity)
1118
1267
  return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, null, {
1119
1268
  error: { code: 'PR_NOT_LINKED', message: 'Task has no verified bound pull request', retryable: false }
1120
1269
  }));
1121
- const fetched = inspectGitHubPullRequest(base.client, base.context.platform.repository, base.prNumber, base.resolved.repoRoot);
1122
- if (!fetched.ok)
1123
- return softenFailure(result(fetched.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, { error: fetched.error }));
1124
- if (!options.metadata && !options.closingIssue)
1125
- return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1126
- error: { code: 'PR_PAYLOAD_INVALID', message: 'sync requires a desired-state option', retryable: false }
1127
- }));
1128
- if (!base.issueNumber)
1129
- return softenFailure(result('degraded', base.resolved.taskId, null, base.prNumber, {
1130
- pullRequest: fetched.value, error: { code: 'ISSUE_NOT_LINKED', message: 'Task has no linked Issue to copy metadata from', retryable: false }
1131
- }));
1132
- const issue = inspectPlatformIssue(taskRef, { cwd: base.resolved.repoRoot, client: base.client });
1133
- if (!issue.issue)
1134
- return softenFailure(result(issue.status, base.resolved.taskId, base.issueNumber, base.prNumber, {
1135
- platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: fetched.value, error: issue.error
1136
- }));
1137
- const planned = planPullRequestMetadata({
1138
- pullRequest: fetched.value,
1139
- issue: issue.issue,
1140
- taskType: String(base.frontmatter.type || 'task'),
1141
- issueNumber: base.issueNumber,
1142
- capabilities: base.context.capabilities
1143
- }).operations.filter((operation) => options.metadata || operation.name === 'closing-issue')
1144
- .filter((operation) => options.closingIssue || operation.name !== 'closing-issue');
1145
- const operations = planned.map(({ name, status, reasonCode }) => ({ name, status, reasonCode }));
1146
- if (options.dryRun)
1147
- return softenFailure(result(planned.some((operation) => operation.status === 'planned') ? 'planned' : 'no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
1148
- platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: fetched.value, operations, error: null
1149
- }));
1150
- const payload = {};
1151
- for (const operation of planned) {
1152
- if (operation.status !== 'planned')
1153
- continue;
1154
- if (operation.name === 'labels')
1155
- payload.labels = operation.value;
1156
- if (operation.name === 'assignees')
1157
- payload.assignees = operation.value;
1158
- if (operation.name === 'closing-issue')
1159
- payload.body = operation.value;
1160
- if (operation.name === 'milestone') {
1161
- const milestones = base.client.json(['api', '--paginate', '--slurp', `repos/${base.context.platform.repository}/milestones?state=open&per_page=100`], { cwd: base.resolved.repoRoot });
1270
+ {
1271
+ const inspected = await inspectExternalPullRequest(base, base.prIdentity);
1272
+ if (inspected.status === 'failed' || inspected.status === 'blocked' || !inspected.pullRequest)
1273
+ return softenFailure(inspected);
1274
+ if (!options.metadata && !options.closingIssue)
1275
+ return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1276
+ error: { code: 'PR_PAYLOAD_INVALID', message: 'sync requires a desired-state option', retryable: false }
1277
+ }));
1278
+ const issue = await inspectPlatformIssue(taskRef, { cwd: base.resolved.repoRoot, client: base.client });
1279
+ if (!issue.issue)
1280
+ return softenFailure(result(issue.status, base.resolved.taskId, base.issueNumber, base.prNumber, {
1281
+ platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: inspected.pullRequest, error: issue.error
1282
+ }));
1283
+ let inLabels = inspected.pullRequest.labels.filter((label) => label.startsWith('in:')).sort();
1284
+ let issueInLabelOperation = null;
1285
+ if (options.metadata && base.provider.issues?.listLabels) {
1286
+ const labels = await base.provider.issues.listLabels({ context: providerOperationContext(base.loadedContext) });
1287
+ if (!labels.ok)
1288
+ return softenFailure(providerPullRequestError(base, labels.error, base.prNumber));
1289
+ const target = taskInLabelTarget(base.resolved.repoRoot, base.frontmatter, inspected.pullRequest.labels, new Set(labels.value));
1290
+ if (!target.ok)
1291
+ return softenFailure(providerPullRequestError(base, target.error, base.prNumber));
1292
+ inLabels = target.value;
1293
+ const currentIssueLabels = issue.issue.labels.filter((label) => label.startsWith('in:')).sort();
1294
+ issueInLabelOperation = base.context.capabilities.triage
1295
+ ? currentIssueLabels.join('\0') === inLabels.join('\0')
1296
+ ? { name: 'labels:in:issue', status: 'no-op', reasonCode: null }
1297
+ : { name: 'labels:in:issue', status: 'planned', reasonCode: null }
1298
+ : { name: 'labels:in:issue', status: 'skipped', reasonCode: 'TRIAGE_REQUIRED' };
1299
+ }
1300
+ const planned = planPullRequestMetadata({
1301
+ pullRequest: inspected.pullRequest,
1302
+ issue: issue.issue,
1303
+ taskType: String(base.frontmatter.type || 'task'),
1304
+ issueNumber: base.issueNumber,
1305
+ capabilities: base.context.capabilities,
1306
+ inLabels
1307
+ }).operations.filter((operation) => options.metadata || operation.name === 'closing-issue')
1308
+ .filter((operation) => options.closingIssue || operation.name !== 'closing-issue');
1309
+ const operations = [
1310
+ ...(issueInLabelOperation ? [issueInLabelOperation] : []),
1311
+ ...planned.map(({ name, status, reasonCode }) => ({ name, status, reasonCode }))
1312
+ ];
1313
+ const milestoneOperation = planned.find((operation) => operation.name === 'milestone' && operation.status === 'planned');
1314
+ if (milestoneOperation && base.provider.issues?.listMilestones) {
1315
+ const milestones = await base.provider.issues.listMilestones({ context: providerOperationContext(base.loadedContext) });
1162
1316
  if (!milestones.ok)
1163
- return softenFailure(result(milestones.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, { pullRequest: fetched.value, operations, error: milestones.error }));
1164
- const flat = milestones.value.flatMap((entry) => Array.isArray(entry) ? entry : [entry]);
1165
- payload.milestone = flat.find((entry) => entry.title === operation.value)?.number ?? null;
1317
+ return providerPullRequestError(base, milestones.error, base.prNumber);
1166
1318
  }
1167
- }
1168
- if (Object.keys(payload).length === 0) {
1169
- const degraded = planned.some((operation) => operation.status === 'skipped');
1170
- return softenFailure(result(degraded ? 'degraded' : 'no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
1171
- platform: base.context.platform, capabilities: base.context.capabilities, resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: fetched.value, operations, error: null
1319
+ if (options.dryRun)
1320
+ return softenFailure(result(planned.some((operation) => operation.status === 'planned') ? 'planned' : 'no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
1321
+ platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: inspected.pullRequest, operations, error: null
1322
+ }));
1323
+ let issueChanged = false;
1324
+ if (issueInLabelOperation?.status === 'planned') {
1325
+ const desiredIssueLabels = [...new Set([
1326
+ ...issue.issue.labels.filter((label) => !label.startsWith('in:')),
1327
+ ...inLabels
1328
+ ])].sort();
1329
+ const updatedIssue = base.provider.issues?.update
1330
+ ? await base.provider.issues.update({
1331
+ context: providerOperationContext(base.loadedContext),
1332
+ target: base.issueIdentity,
1333
+ currentLabels: issue.issue.labels,
1334
+ patch: { labels: desiredIssueLabels },
1335
+ mutation: { idempotencyKey: `issue:update:${base.resolved.taskId}:in-labels` }
1336
+ })
1337
+ : unsupportedProviderOperation(base.provider, 'issues.update');
1338
+ if (!updatedIssue.ok)
1339
+ return softenFailure(providerPullRequestError(base, updatedIssue.error, base.prNumber));
1340
+ issueChanged = updatedIssue.value.changed;
1341
+ const rereadIssue = base.provider.issues?.inspect
1342
+ ? await base.provider.issues.inspect({ context: providerOperationContext(base.loadedContext), target: base.issueIdentity })
1343
+ : unsupportedProviderOperation(base.provider, 'issues.inspect');
1344
+ if (!rereadIssue.ok)
1345
+ return softenFailure(providerPullRequestError(base, {
1346
+ ...rereadIssue.error,
1347
+ code: 'IN_LABEL_SYNC_PARTIAL',
1348
+ message: `In-label synchronization is partial or unknown: ${rereadIssue.error.message}`
1349
+ }, base.prNumber));
1350
+ const actualIssueLabels = rereadIssue.value.labels.filter((label) => label.startsWith('in:')).sort();
1351
+ if (actualIssueLabels.join('\0') !== inLabels.join('\0'))
1352
+ return softenFailure(providerPullRequestError(base, {
1353
+ code: 'IN_LABEL_SYNC_PARTIAL',
1354
+ message: 'Issue in: labels did not converge after update',
1355
+ retryable: true
1356
+ }, base.prNumber));
1357
+ }
1358
+ const patch = {};
1359
+ for (const operation of planned) {
1360
+ if (operation.status !== 'planned')
1361
+ continue;
1362
+ if (operation.name === 'labels')
1363
+ patch.labels = operation.value;
1364
+ if (operation.name === 'assignees')
1365
+ patch.assignees = operation.value;
1366
+ if (operation.name === 'closing-issue')
1367
+ patch.body = operation.value;
1368
+ if (operation.name === 'milestone')
1369
+ patch.milestone = operation.value;
1370
+ }
1371
+ if (Object.keys(patch).length === 0)
1372
+ return softenFailure(result(planned.some((operation) => operation.status === 'skipped') ? 'degraded' : 'no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
1373
+ platform: base.context.platform, capabilities: base.context.capabilities, resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: inspected.pullRequest, operations, error: null
1374
+ }));
1375
+ const updated = base.provider.changeRequests?.update
1376
+ ? await base.provider.changeRequests.update({
1377
+ context: providerOperationContext(base.loadedContext),
1378
+ target: base.prIdentity,
1379
+ currentLabels: inspected.pullRequest.labels,
1380
+ patch: patch,
1381
+ mutation: { idempotencyKey: `pull-request:update:${base.resolved.taskId}` }
1382
+ })
1383
+ : unsupportedProviderOperation(base.provider, 'changeRequests.update');
1384
+ if (!updated.ok) {
1385
+ const error = issueChanged && !updated.error.code.startsWith('IN_LABEL_SYNC')
1386
+ ? { ...updated.error, code: 'IN_LABEL_SYNC_PARTIAL', message: `In-label synchronization is partial or unknown: ${updated.error.message}` }
1387
+ : updated.error;
1388
+ return softenFailure(providerPullRequestError(base, error, base.prNumber));
1389
+ }
1390
+ let finalPullRequest = inspected.pullRequest;
1391
+ if (planned.some((operation) => operation.name === 'labels' && operation.status === 'planned')) {
1392
+ const reread = base.provider.changeRequests?.inspect
1393
+ ? await base.provider.changeRequests.inspect({ context: providerOperationContext(base.loadedContext), target: base.prIdentity })
1394
+ : unsupportedProviderOperation(base.provider, 'changeRequests.inspect');
1395
+ if (!reread.ok)
1396
+ return softenFailure(providerPullRequestError(base, {
1397
+ ...reread.error,
1398
+ code: 'IN_LABEL_SYNC_PARTIAL',
1399
+ message: `In-label synchronization is partial or unknown: ${reread.error.message}`
1400
+ }, base.prNumber));
1401
+ finalPullRequest = normalizeProviderPullRequest(reread.value, base.context.platform.repository, base.prNumber || 0, inspected.pullRequest);
1402
+ const expected = ((planned.find((operation) => operation.name === 'labels')?.value || [])
1403
+ .filter((label) => label.startsWith('in:') || label.startsWith('type:'))).sort();
1404
+ const actual = finalPullRequest.labels.filter((label) => label.startsWith('in:') || label.startsWith('type:')).sort();
1405
+ if (actual.join('\0') !== expected.join('\0'))
1406
+ return softenFailure(providerPullRequestError(base, {
1407
+ code: 'IN_LABEL_SYNC_PARTIAL',
1408
+ message: 'Pull request labels did not converge after update',
1409
+ retryable: true
1410
+ }, base.prNumber));
1411
+ }
1412
+ return softenFailure(result(planned.some((operation) => operation.status === 'skipped') ? 'degraded' : 'applied', base.resolved.taskId, base.issueNumber, base.prNumber, {
1413
+ changed: issueChanged || updated.value.changed,
1414
+ platform: base.context.platform,
1415
+ capabilities: base.context.capabilities,
1416
+ resource: { kind: 'pull-request', number: base.prNumber },
1417
+ pullRequest: finalPullRequest,
1418
+ operations: operations.map((operation) => operation.status === 'planned' ? { ...operation, status: 'applied' } : operation),
1419
+ error: null
1172
1420
  }));
1173
1421
  }
1174
- const patched = base.client.json(['api', `repos/${base.context.platform.repository}/issues/${base.prNumber}`, '-X', 'PATCH', '--input', '-'], {
1175
- cwd: base.resolved.repoRoot, method: 'PATCH', input: JSON.stringify(payload)
1176
- });
1177
- if (!patched.ok)
1178
- return softenFailure(result(patched.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, { pullRequest: fetched.value, operations, error: patched.error }));
1179
- return softenFailure(result(planned.some((operation) => operation.status === 'skipped') ? 'degraded' : 'applied', base.resolved.taskId, base.issueNumber, base.prNumber, {
1180
- changed: true, platform: base.context.platform, capabilities: base.context.capabilities,
1181
- resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: fetched.value,
1182
- operations: operations.map((operation) => operation.status === 'planned' ? { ...operation, status: 'applied' } : operation), error: null
1183
- }));
1184
1422
  }
1185
1423
  function readProjectPrFlow(repoRoot) {
1186
1424
  try {
@@ -1203,12 +1441,18 @@ function skipPlatformPullRequestFactUnlocked(resolved, options) {
1203
1441
  error: { code: 'TASK_DOCUMENT_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
1204
1442
  });
1205
1443
  }
1206
- const issue = Number(frontmatter.issue_number);
1207
- const issueNumber = Number.isSafeInteger(issue) && issue > 0 ? issue : null;
1208
- const existing = readPrDeliveryFact(frontmatter);
1444
+ let issueIdentity;
1445
+ try {
1446
+ issueIdentity = taskIssueIdentity(frontmatter, undefined, options.runtimeVersion);
1447
+ }
1448
+ catch (error) {
1449
+ return result('failed', resolved.taskId, null, null, { error: { ...taskIssueIdentityError(error), retryable: false } });
1450
+ }
1451
+ const issueNumber = resourceIdentityNumber(issueIdentity);
1452
+ const existing = readPrDeliveryFact(frontmatter, options.runtimeVersion);
1209
1453
  if (existing.status === 'invalid')
1210
1454
  return result('failed', resolved.taskId, issueNumber, null, {
1211
- error: { code: 'PR_DELIVERY_FACT_INVALID', message: existing.error.message, retryable: false }
1455
+ error: { code: existing.error.code, message: existing.error.message, retryable: false }
1212
1456
  });
1213
1457
  if (existing.status === 'missing')
1214
1458
  return result('failed', resolved.taskId, issueNumber, null, {
@@ -1219,10 +1463,12 @@ function skipPlatformPullRequestFactUnlocked(resolved, options) {
1219
1463
  operations: [{ name: 'task:skip-pr', status: 'no-op', reasonCode: null }],
1220
1464
  error: null
1221
1465
  });
1222
- if (existing.fact.state === 'bound')
1223
- return result('failed', resolved.taskId, issueNumber, existing.fact.identity.number, {
1224
- error: { code: 'PR_DELIVERY_FACT_CONFLICT', message: `Task is already bound to PR #${existing.fact.identity.number}`, retryable: false }
1466
+ if (existing.fact.state === 'bound') {
1467
+ const boundNumber = resourceIdentityNumber(existing.fact.identity.resource);
1468
+ return result('failed', resolved.taskId, issueNumber, boundNumber, {
1469
+ error: { code: 'PR_DELIVERY_FACT_CONFLICT', message: `Task is already bound to PR ${boundNumber === null ? 'identity' : `#${boundNumber}`}`, retryable: false }
1225
1470
  });
1471
+ }
1226
1472
  if (readProjectPrFlow(resolved.repoRoot) === 'required')
1227
1473
  return result('failed', resolved.taskId, issueNumber, null, {
1228
1474
  error: { code: 'PR_SKIP_FORBIDDEN', message: 'Cannot skip PR delivery when prFlow is required', retryable: false }
@@ -1249,7 +1495,7 @@ function skipPlatformPullRequestFactUnlocked(resolved, options) {
1249
1495
  error: null
1250
1496
  });
1251
1497
  }
1252
- function skipPlatformPullRequestFact(taskRef, options) {
1498
+ async function skipPlatformPullRequestFact(taskRef, options) {
1253
1499
  const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
1254
1500
  if (!resolved.ok)
1255
1501
  return result('failed', resolved.taskId, null, null, {
@@ -1272,6 +1518,6 @@ function skipPlatformPullRequestFact(taskRef, options) {
1272
1518
  });
1273
1519
  }
1274
1520
  }
1275
- export { bindPlatformPullRequest, createPlatformPullRequest, inspectGitHubPullRequest, inspectGitHubIssueClosingChangeRequests, inspectPlatformPullRequest, inspectPlatformPullRequestByNumber, normalizePullRequest, resolveGitHubChangeRequestGitEvidence, resolveExternalPullRequest, skipPlatformPullRequestFact, selectExternalPullRequest, selectPullRequest, syncPlatformPullRequest };
1521
+ export { bindPlatformPullRequest, createPlatformPullRequest, inspectGitHubPullRequest, inspectGitHubIssueClosingChangeRequests, inspectPlatformPullRequest, inspectPlatformPullRequestByNumber, normalizePullRequest, resolveExternalPullRequest, skipPlatformPullRequestFact, selectExternalPullRequest, selectPullRequest, syncPlatformPullRequest, syncPlatformPullRequestInLabels };
1276
1522
  export { warningResultForPrimary };
1277
1523
  //# sourceMappingURL=pull-requests.js.map