@fitlab-ai/agent-infra 0.9.11 → 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.
- package/README.md +13 -13
- package/README.zh-CN.md +13 -13
- package/dist/bin/internal-cli.js +29 -19
- package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +71 -10
- package/dist/lib/agent-clients/next-steps.js +6 -1
- package/dist/lib/decide.js +3 -3
- package/dist/lib/defaults.json +4 -0
- package/dist/lib/git/workflow.js +43 -1
- package/dist/lib/init.js +1 -0
- package/dist/lib/internal/git-workflow.js +8 -1
- package/dist/lib/internal/platform-checks.js +3 -3
- package/dist/lib/internal/platform-comment.js +9 -9
- package/dist/lib/internal/platform-context.js +2 -2
- package/dist/lib/internal/platform-issue.js +8 -9
- package/dist/lib/internal/platform-pr-review.js +10 -10
- package/dist/lib/internal/platform-pr.js +68 -23
- package/dist/lib/internal/platform-release-notes.js +3 -3
- package/dist/lib/internal/pr-review-grade.js +11 -9
- package/dist/lib/internal/release-workflow.js +4 -4
- package/dist/lib/internal/sandbox-control.js +30 -4
- package/dist/lib/internal/task-activity.js +3 -3
- package/dist/lib/internal/task-artifact.js +76 -24
- package/dist/lib/internal/task-create.js +2 -2
- package/dist/lib/internal/task-delivery.js +59 -0
- package/dist/lib/internal/task-event.js +6 -3
- package/dist/lib/internal/task-finalization.js +2 -2
- package/dist/lib/internal/task-invalidation.js +60 -0
- package/dist/lib/internal/task-ledger.js +9 -6
- package/dist/lib/internal/task-lifecycle.js +2 -2
- package/dist/lib/internal/task-override.js +4 -4
- package/dist/lib/internal/task-review.js +3 -3
- package/dist/lib/internal/task-verify.js +6 -6
- package/dist/lib/internal/task-warning.js +3 -3
- package/dist/lib/platform/adapters.js +1 -3
- package/dist/lib/platform/change-request-git-evidence.js +27 -7
- package/dist/lib/platform/completion-backfill.js +6 -5
- package/dist/lib/platform/context.js +70 -130
- package/dist/lib/platform/github-client.js +8 -2
- package/dist/lib/platform/github-provider.js +903 -0
- package/dist/lib/platform/in-label-sync.js +143 -0
- package/dist/lib/platform/issue-comments.js +94 -41
- package/dist/lib/platform/issues.js +335 -189
- package/dist/lib/platform/metadata-labels.js +6 -2
- package/dist/lib/platform/none-provider.js +28 -0
- package/dist/lib/platform/pr-change-report.js +551 -0
- package/dist/lib/platform/pr-checks.js +124 -106
- package/dist/lib/platform/pr-review.js +88 -72
- package/dist/lib/platform/pr-summary.js +523 -49
- package/dist/lib/platform/provider-bridge.js +45 -0
- package/dist/lib/platform/provider-contract.d.ts +487 -0
- package/dist/lib/platform/provider-contract.js +95 -0
- package/dist/lib/platform/provider-loader.js +251 -0
- package/dist/lib/platform/provider-validation.js +504 -0
- package/dist/lib/platform/pull-request-metadata.js +8 -6
- package/dist/lib/platform/pull-requests.js +1050 -502
- package/dist/lib/platform/release-notes.js +78 -55
- package/dist/lib/platform/releases.js +79 -50
- package/dist/lib/platform/resource-identity.d.ts +26 -0
- package/dist/lib/platform/resource-identity.js +92 -0
- package/dist/lib/platform/task-identities.js +31 -0
- package/dist/lib/platform/verification-required.js +22 -12
- package/dist/lib/platform/verification-sync.js +118 -239
- package/dist/lib/pr-review/evidence-grading.js +30 -9
- package/dist/lib/run/index.js +63 -23
- package/dist/lib/sandbox/config.js +5 -0
- package/dist/lib/sandbox/control/client.js +96 -15
- package/dist/lib/sandbox/control/executor.js +3 -3
- package/dist/lib/sandbox/control/lifecycle.js +29 -7
- package/dist/lib/sandbox/control/protocol.js +77 -0
- package/dist/lib/sandbox/control/server.js +398 -25
- package/dist/lib/sandbox/control/state.js +204 -1
- package/dist/lib/server/process-state.js +2 -2
- package/dist/lib/server/protocol.js +38 -1
- package/dist/lib/task/activity-log.js +8 -1
- package/dist/lib/task/artifact-lifecycle.js +35 -7
- package/dist/lib/task/capabilities.js +239 -0
- package/dist/lib/task/commands/status.js +32 -0
- package/dist/lib/task/commit-intent.js +100 -0
- package/dist/lib/task/commit-operation.js +207 -102
- package/dist/lib/task/control-authority.js +8 -5
- package/dist/lib/task/create-service.js +8 -8
- package/dist/lib/task/create.js +33 -7
- package/dist/lib/task/current-contract.js +5 -1
- package/dist/lib/task/decision-details.js +3 -48
- package/dist/lib/task/delivery-target.js +97 -0
- package/dist/lib/task/delivery.js +153 -0
- package/dist/lib/task/events.js +297 -17
- package/dist/lib/task/finalization.js +37 -13
- package/dist/lib/task/human-override.js +61 -36
- package/dist/lib/task/invalidation-command.js +86 -0
- package/dist/lib/task/invalidation.js +260 -0
- package/dist/lib/task/ledger-intents.js +37 -0
- package/dist/lib/task/lifecycle.js +106 -9
- package/dist/lib/task/local-artifact-finalization.js +374 -0
- package/dist/lib/task/orchestration.js +100 -151
- package/dist/lib/task/pr-delivery-fact.js +183 -0
- package/dist/lib/task/review-finalization.js +4 -8
- package/dist/lib/task/review-fingerprint.js +32 -2
- package/dist/lib/task/rework-intent.js +138 -0
- package/dist/lib/task/skill-scope.js +23 -0
- package/dist/lib/task/task-execution-lock.js +27 -17
- package/dist/lib/task/verification-engine.js +89 -38
- package/dist/lib/task/verification-review.js +1 -1
- package/dist/lib/task/verification.js +3 -3
- package/dist/lib/task/workflow-warning-intents.js +1 -1
- package/dist/lib/task/write.js +11 -0
- package/dist/lib/update.js +9 -0
- package/dist/lib/version.js +11 -1
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +105 -10
- package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +12 -1
- package/lib/agent-clients/next-steps.ts +6 -1
- package/lib/decide.ts +3 -3
- package/lib/defaults.json +4 -0
- package/lib/git/workflow.ts +38 -1
- package/lib/init.ts +4 -1
- package/lib/internal/git-workflow.ts +7 -1
- package/lib/internal/platform-checks.ts +3 -3
- package/lib/internal/platform-comment.ts +8 -8
- package/lib/internal/platform-context.ts +2 -2
- package/lib/internal/platform-issue.ts +7 -8
- package/lib/internal/platform-pr-review.ts +9 -9
- package/lib/internal/platform-pr.ts +53 -21
- package/lib/internal/platform-release-notes.ts +3 -3
- package/lib/internal/pr-review-grade.ts +10 -8
- package/lib/internal/release-workflow.ts +4 -4
- package/lib/internal/sandbox-control.ts +27 -3
- package/lib/internal/task-activity.ts +3 -3
- package/lib/internal/task-artifact.ts +65 -22
- package/lib/internal/task-create.ts +2 -2
- package/lib/internal/task-delivery.ts +53 -0
- package/lib/internal/task-event.ts +6 -3
- package/lib/internal/task-finalization.ts +3 -3
- package/lib/internal/task-invalidation.ts +39 -0
- package/lib/internal/task-ledger.ts +11 -8
- package/lib/internal/task-lifecycle.ts +2 -2
- package/lib/internal/task-override.ts +6 -6
- package/lib/internal/task-review.ts +3 -3
- package/lib/internal/task-verify.ts +6 -6
- package/lib/internal/task-warning.ts +3 -3
- package/lib/platform/adapters.ts +3 -2
- package/lib/platform/change-request-git-evidence.ts +36 -9
- package/lib/platform/completion-backfill.ts +9 -8
- package/lib/platform/context.ts +97 -134
- package/lib/platform/github-client.ts +10 -3
- package/lib/platform/github-provider.ts +910 -0
- package/lib/platform/in-label-sync.ts +208 -0
- package/lib/platform/issue-comments.ts +97 -47
- package/lib/platform/issues.ts +344 -174
- package/lib/platform/metadata-labels.ts +5 -2
- package/lib/platform/none-provider.ts +35 -0
- package/lib/platform/pr-change-report.ts +645 -0
- package/lib/platform/pr-checks.ts +129 -107
- package/lib/platform/pr-review.ts +88 -85
- package/lib/platform/pr-summary.ts +562 -52
- package/lib/platform/provider-bridge.ts +82 -0
- package/lib/platform/provider-contract.ts +536 -0
- package/lib/platform/provider-loader.ts +307 -0
- package/lib/platform/provider-validation.ts +529 -0
- package/lib/platform/pull-request-metadata.ts +10 -7
- package/lib/platform/pull-requests.ts +1126 -505
- package/lib/platform/release-notes.ts +76 -59
- package/lib/platform/releases.ts +77 -43
- package/lib/platform/resource-identity.ts +121 -0
- package/lib/platform/task-identities.ts +40 -0
- package/lib/platform/types.ts +4 -2
- package/lib/platform/verification-required.ts +21 -13
- package/lib/platform/verification-sync.ts +118 -293
- package/lib/pr-review/evidence-grading.ts +31 -11
- package/lib/run/index.ts +57 -22
- package/lib/sandbox/config.ts +7 -0
- package/lib/sandbox/control/client.ts +105 -15
- package/lib/sandbox/control/executor.ts +4 -4
- package/lib/sandbox/control/lifecycle.ts +30 -8
- package/lib/sandbox/control/protocol.ts +113 -0
- package/lib/sandbox/control/server.ts +431 -23
- package/lib/sandbox/control/state.ts +251 -1
- package/lib/server/process-state.ts +2 -2
- package/lib/server/protocol.ts +36 -1
- package/lib/task/activity-log.ts +8 -0
- package/lib/task/artifact-lifecycle.ts +34 -8
- package/lib/task/capabilities.ts +262 -0
- package/lib/task/commands/status.ts +38 -0
- package/lib/task/commit-intent.ts +137 -0
- package/lib/task/commit-operation.ts +186 -88
- package/lib/task/control-authority.ts +11 -8
- package/lib/task/create-service.ts +8 -8
- package/lib/task/create.ts +34 -9
- package/lib/task/current-contract.ts +6 -1
- package/lib/task/decision-details.ts +8 -64
- package/lib/task/delivery-target.ts +130 -0
- package/lib/task/delivery.ts +160 -0
- package/lib/task/events.ts +296 -16
- package/lib/task/finalization.ts +50 -14
- package/lib/task/human-override.ts +73 -46
- package/lib/task/invalidation-command.ts +86 -0
- package/lib/task/invalidation.ts +332 -0
- package/lib/task/ledger-intents.ts +35 -1
- package/lib/task/lifecycle.ts +93 -4
- package/lib/task/local-artifact-finalization.ts +563 -0
- package/lib/task/orchestration.ts +110 -146
- package/lib/task/pr-delivery-fact.ts +237 -0
- package/lib/task/review-finalization.ts +4 -14
- package/lib/task/review-fingerprint.ts +33 -2
- package/lib/task/rework-intent.ts +165 -0
- package/lib/task/skill-scope.ts +24 -0
- package/lib/task/task-execution-lock.ts +23 -14
- package/lib/task/verification-engine.ts +95 -43
- package/lib/task/verification-review.ts +2 -0
- package/lib/task/verification.ts +4 -4
- package/lib/task/workflow-warning-intents.ts +1 -1
- package/lib/task/write.ts +19 -0
- package/lib/update.ts +11 -1
- package/lib/version.ts +15 -1
- package/package.json +7 -1
- package/templates/.agents/rules/README.en.md +4 -2
- package/templates/.agents/rules/README.zh-CN.md +4 -2
- package/templates/.agents/rules/create-issue.en.md +11 -2
- package/templates/.agents/rules/create-issue.zh-CN.md +24 -2
- package/templates/.agents/rules/issue-pr-commands.en.md +4 -17
- package/templates/.agents/rules/issue-pr-commands.zh-CN.md +1 -1
- package/templates/.agents/rules/issue-sync.en.md +28 -5
- package/templates/.agents/rules/issue-sync.zh-CN.md +64 -5
- package/templates/.agents/rules/label-milestone-setup.en.md +15 -2
- package/templates/.agents/rules/label-milestone-setup.zh-CN.md +15 -2
- package/templates/.agents/rules/local-artifact-repair.en.md +53 -0
- package/templates/.agents/rules/local-artifact-repair.zh-CN.md +53 -0
- package/templates/.agents/rules/milestone-inference.en.md +12 -2
- package/templates/.agents/rules/milestone-inference.zh-CN.md +25 -3
- package/templates/.agents/rules/pr-sync.en.md +28 -13
- package/templates/.agents/rules/pr-sync.zh-CN.md +39 -5
- package/templates/.agents/rules/release-commands.en.md +42 -3
- package/templates/.agents/rules/release-commands.zh-CN.md +42 -3
- package/templates/.agents/rules/security-alerts.en.md +17 -2
- package/templates/.agents/rules/security-alerts.zh-CN.md +17 -2
- package/templates/.agents/rules/sync-content-generation.en.md +8 -0
- package/templates/.agents/rules/sync-content-generation.zh-CN.md +8 -0
- package/templates/.agents/rules/task-management.en.md +1 -1
- package/templates/.agents/rules/task-management.zh-CN.md +1 -1
- package/templates/.agents/rules/task-short-id.en.md +1 -4
- package/templates/.agents/rules/task-short-id.zh-CN.md +1 -3
- package/templates/.agents/scripts/security-alerts.github.sh +151 -0
- package/templates/.agents/scripts/security-alerts.sh +31 -0
- package/templates/.agents/skills/analyze-task/SKILL.en.md +14 -3
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +11 -3
- package/templates/.agents/skills/check-task/SKILL.en.md +16 -37
- package/templates/.agents/skills/check-task/SKILL.zh-CN.md +16 -37
- package/templates/.agents/skills/close-codescan/SKILL.en.md +2 -2
- package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/close-dependabot/SKILL.en.md +2 -2
- package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/code-task/SKILL.en.md +32 -10
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +35 -13
- package/templates/.agents/skills/code-task/config/verify.en.json +1 -0
- package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -0
- package/templates/.agents/skills/commit/SKILL.en.md +3 -3
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/commit/config/verify.json +1 -1
- package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +3 -3
- package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +3 -3
- package/templates/.agents/skills/commit/reference/issue-metadata-sync.en.md +2 -0
- package/templates/.agents/skills/commit/reference/issue-metadata-sync.zh-CN.md +2 -0
- package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +4 -4
- package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +4 -4
- package/templates/.agents/skills/commit/reference/task-status-update.en.md +2 -2
- package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -2
- package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +7 -4
- package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +7 -4
- package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +1 -1
- package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +5 -4
- package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +5 -4
- package/templates/.agents/skills/complete-task/SKILL.en.md +31 -13
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +30 -13
- package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +2 -2
- package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +2 -2
- package/templates/.agents/skills/create-pr/SKILL.en.md +11 -10
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +11 -11
- package/templates/.agents/skills/create-pr/config/verify.json +1 -0
- package/templates/.agents/skills/create-pr/reference/change-report.en.md +16 -4
- package/templates/.agents/skills/create-pr/reference/change-report.zh-CN.md +16 -4
- package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +5 -3
- package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +5 -3
- package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +1 -1
- package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +1 -1
- package/templates/.agents/skills/create-pr/scripts/change-report.mjs +3 -0
- package/templates/.agents/skills/create-task/SKILL.en.md +1 -0
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -0
- package/templates/.agents/skills/import-codescan/SKILL.en.md +1 -1
- package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/import-dependabot/SKILL.en.md +1 -1
- package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/init-labels/SKILL.en.md +9 -10
- package/templates/.agents/skills/init-labels/SKILL.zh-CN.md +9 -10
- package/templates/.agents/skills/init-labels/scripts/init-labels.github.sh +57 -1
- package/templates/.agents/skills/init-labels/scripts/init-labels.sh +27 -1
- package/templates/.agents/skills/init-milestones/SKILL.en.md +3 -5
- package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +3 -5
- package/templates/.agents/skills/plan-task/SKILL.en.md +14 -3
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +11 -3
- package/templates/.agents/skills/review-analysis/SKILL.en.md +6 -5
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +6 -5
- package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +18 -0
- package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +18 -0
- package/templates/.agents/skills/review-code/SKILL.en.md +10 -9
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +10 -9
- package/templates/.agents/skills/review-code/reference/output-templates.en.md +19 -1
- package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +19 -1
- package/templates/.agents/skills/review-code/reference/report-template.en.md +4 -2
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +4 -2
- package/templates/.agents/skills/review-plan/SKILL.en.md +6 -5
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +6 -5
- package/templates/.agents/skills/review-plan/reference/output-templates.en.md +18 -0
- package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +18 -0
- package/templates/.agents/skills/review-pr/SKILL.en.md +1 -0
- package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +1 -0
- package/templates/.agents/skills/review-pr/reference/evidence-grading.en.md +2 -2
- package/templates/.agents/skills/review-pr/reference/evidence-grading.zh-CN.md +2 -2
- package/templates/.agents/skills/run-manual-validation/SKILL.en.md +5 -2
- package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +5 -2
- package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +4 -0
- package/templates/.agents/skills/watch-pr/SKILL.en.md +6 -6
- package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +6 -6
- package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +6 -0
- package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +6 -0
- package/templates/.agents/templates/task.en.md +23 -2
- package/templates/.agents/templates/task.zh-CN.md +23 -2
- package/templates/.agents/workflows/bug-fix.en.yaml +9 -8
- package/templates/.agents/workflows/bug-fix.zh-CN.yaml +9 -8
- package/templates/.agents/workflows/feature-development.en.yaml +9 -8
- package/templates/.agents/workflows/feature-development.zh-CN.yaml +9 -8
- package/templates/.agents/workflows/refactoring.en.yaml +9 -8
- package/templates/.agents/workflows/refactoring.zh-CN.yaml +9 -8
- package/templates/.github/workflows/pr-label.yml +7 -26
- package/templates/.agents/rules/create-issue.github.en.md +0 -14
- package/templates/.agents/rules/create-issue.github.zh-CN.md +0 -27
- package/templates/.agents/rules/issue-pr-commands.github.en.md +0 -34
- package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +0 -49
- package/templates/.agents/rules/issue-sync.github.en.md +0 -40
- package/templates/.agents/rules/issue-sync.github.zh-CN.md +0 -76
- package/templates/.agents/rules/label-milestone-setup.github.en.md +0 -60
- package/templates/.agents/rules/label-milestone-setup.github.zh-CN.md +0 -60
- package/templates/.agents/rules/milestone-inference.github.en.md +0 -15
- package/templates/.agents/rules/milestone-inference.github.zh-CN.md +0 -27
- package/templates/.agents/rules/pr-checks-commands.github.en.md +0 -18
- package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +0 -34
- package/templates/.agents/rules/pr-sync.github.en.md +0 -43
- package/templates/.agents/rules/pr-sync.github.zh-CN.md +0 -69
- package/templates/.agents/rules/release-commands.github.en.md +0 -44
- package/templates/.agents/rules/release-commands.github.zh-CN.md +0 -44
- package/templates/.agents/rules/security-alerts.github.en.md +0 -43
- package/templates/.agents/rules/security-alerts.github.zh-CN.md +0 -43
- /package/templates/.agents/rules/{issue-fields.github.en.md → issue-fields.en.md} +0 -0
- /package/templates/.agents/rules/{issue-fields.github.zh-CN.md → issue-fields.zh-CN.md} +0 -0
|
@@ -1,26 +1,52 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { execFileSync } from 'node:child_process';
|
|
3
4
|
|
|
4
|
-
import {
|
|
5
|
+
import { parseTypedTaskFrontmatter } from '../task/frontmatter.ts';
|
|
5
6
|
import { resolveTaskRef } from '../task/resolve-ref.ts';
|
|
6
7
|
import { extractSection } from '../task/sections.ts';
|
|
7
8
|
import { captureTaskWriteMetadata, writeTask } from '../task/write.ts';
|
|
8
|
-
import {
|
|
9
|
-
inspectPlatformChangeRequest,
|
|
10
|
-
inspectPlatformIssueClosingChangeRequests,
|
|
11
|
-
registerPlatformCapabilities
|
|
12
|
-
} from './adapters.ts';
|
|
13
9
|
import type { PlatformChangeRequestSnapshot } from './adapters.ts';
|
|
14
|
-
import { parseGitHubRemote, resolvePlatformContext } from './context.ts';
|
|
10
|
+
import { parseGitHubRemote, resolvePlatformContext, resolvePlatformProviderContext } from './context.ts';
|
|
11
|
+
import type { PlatformClient } from './context.ts';
|
|
15
12
|
import { createGitHubClient } from './github-client.ts';
|
|
16
|
-
import type { GitHubClient } from './github-client.ts';
|
|
17
13
|
import { inspectPlatformIssue } from './issues.ts';
|
|
14
|
+
import { inspectGitHubIssue } from './issues.ts';
|
|
15
|
+
import type { IssueSnapshot } from './issues.ts';
|
|
18
16
|
import { planPullRequestMetadata } from './pull-request-metadata.ts';
|
|
17
|
+
import {
|
|
18
|
+
extractPullRequestFileNames,
|
|
19
|
+
planInLabelUpdate,
|
|
20
|
+
syncLabelDelta,
|
|
21
|
+
validateInLabelMapping,
|
|
22
|
+
validateRepositoryLabelPayload
|
|
23
|
+
} from './in-label-sync.ts';
|
|
19
24
|
import { platformResult } from './types.ts';
|
|
20
25
|
import type { PlatformOperation, PlatformResult } from './types.ts';
|
|
21
26
|
import { inspectCompletionArtifacts } from '../task/finalization-artifacts.ts';
|
|
22
27
|
import { TaskExecutionLockError, withTaskExecutionLock } from '../task/task-execution-lock.ts';
|
|
28
|
+
import { resolveDeliveryTarget } from '../task/delivery-target.ts';
|
|
23
29
|
import { mergeOperationWarnings, type OperationWarning } from '../task/operation-outcome.ts';
|
|
30
|
+
import {
|
|
31
|
+
buildBoundFact,
|
|
32
|
+
buildSkippedFact,
|
|
33
|
+
factFrontmatterMutation,
|
|
34
|
+
readPrDeliveryFact
|
|
35
|
+
} from '../task/pr-delivery-fact.ts';
|
|
36
|
+
import type { CreationOutcome, PrDeliveryBindingSource, PrDeliveryFact } from '../task/pr-delivery-fact.ts';
|
|
37
|
+
import {
|
|
38
|
+
providerError,
|
|
39
|
+
providerOperationContext,
|
|
40
|
+
providerStatus,
|
|
41
|
+
providerResourceToken,
|
|
42
|
+
providerResourceIdentity,
|
|
43
|
+
resourceIdentity,
|
|
44
|
+
unsupportedProviderOperation
|
|
45
|
+
} from './provider-bridge.ts';
|
|
46
|
+
import { isResourceIdentity, resourceIdentityEquals, resourceIdentityNumber } from './resource-identity.ts';
|
|
47
|
+
import type { ResourceIdentity } from './resource-identity.ts';
|
|
48
|
+
import { taskIssueIdentity, taskIssueIdentityError } from './task-identities.ts';
|
|
49
|
+
import type { ChangeRequestSnapshot as ProviderChangeRequestSnapshot } from './provider-contract.ts';
|
|
24
50
|
|
|
25
51
|
type PullRequestSnapshot = PlatformChangeRequestSnapshot;
|
|
26
52
|
|
|
@@ -28,10 +54,21 @@ type PullRequestResult = PlatformResult & {
|
|
|
28
54
|
task: { id: string | null; issueNumber: number | null; prNumber: number | null };
|
|
29
55
|
pullRequest: PullRequestSnapshot | null;
|
|
30
56
|
result: 'pr_created' | 'pr_reused' | 'no_op' | 'pr_created_with_warnings' | 'pr_reused_with_warnings' | 'no_op_with_warnings' | 'failed' | 'blocked' | null;
|
|
57
|
+
creation: CreationOutcome | null;
|
|
31
58
|
warnings: readonly OperationWarning[];
|
|
59
|
+
evidence?: { kind: string; pullRequestFiles?: string[]; closingIssues?: number[] };
|
|
60
|
+
resources?: Array<{
|
|
61
|
+
kind: 'issue' | 'pull-request';
|
|
62
|
+
number: number;
|
|
63
|
+
before: string[];
|
|
64
|
+
expected: string[];
|
|
65
|
+
after: string[] | null;
|
|
66
|
+
effect: 'no-op' | 'applied' | 'unknown';
|
|
67
|
+
}>;
|
|
32
68
|
};
|
|
33
|
-
type
|
|
34
|
-
type
|
|
69
|
+
type GitHubClient = PlatformClient;
|
|
70
|
+
type InspectionOptions = { cwd?: string; client?: PlatformClient; runtimeVersion?: string };
|
|
71
|
+
type SharedOptions = { cwd?: string; client?: PlatformClient; runtimeVersion?: string };
|
|
35
72
|
type CreateOptions = SharedOptions & {
|
|
36
73
|
agent: string;
|
|
37
74
|
base: string;
|
|
@@ -40,11 +77,13 @@ type CreateOptions = SharedOptions & {
|
|
|
40
77
|
body: string;
|
|
41
78
|
draft?: boolean;
|
|
42
79
|
dryRun?: boolean;
|
|
80
|
+
phase?: { value: CreatePhase };
|
|
43
81
|
};
|
|
44
|
-
type BindOptions = SharedOptions & { agent: string; pr: number; dryRun?: boolean };
|
|
82
|
+
type BindOptions = SharedOptions & { agent: string; pr: string | number; dryRun?: boolean };
|
|
45
83
|
type PullRequestPrimaryResult = 'pr_created' | 'pr_reused' | 'no_op';
|
|
46
84
|
type SyncOptions = SharedOptions & { agent: string; metadata?: boolean; closingIssue?: boolean; dryRun?: boolean; primaryResult: PullRequestPrimaryResult };
|
|
47
|
-
type ResolveExternalOptions = SharedOptions & { agent: string; pr?: number; dryRun?: boolean };
|
|
85
|
+
type ResolveExternalOptions = SharedOptions & { agent: string; pr?: string | number; dryRun?: boolean };
|
|
86
|
+
type SkipFactOptions = { cwd?: string; agent: string; dryRun?: boolean; runtimeVersion?: string };
|
|
48
87
|
type ExternalPullRequestSelection =
|
|
49
88
|
| { status: 'normal'; candidates: PullRequestSnapshot[]; eligible: [] }
|
|
50
89
|
| { status: 'selected'; source: 'unique' | 'explicit'; selected: PullRequestSnapshot; candidates: PullRequestSnapshot[]; eligible: PullRequestSnapshot[] }
|
|
@@ -57,6 +96,8 @@ type ExternalPullRequestResult = PullRequestResult & {
|
|
|
57
96
|
selected: PullRequestSnapshot | null;
|
|
58
97
|
};
|
|
59
98
|
|
|
99
|
+
type CreatePhase = 'before-post' | 'post-dispatched' | 'post-accepted';
|
|
100
|
+
|
|
60
101
|
type RemotePullRequest = {
|
|
61
102
|
number?: number;
|
|
62
103
|
node_id?: string;
|
|
@@ -123,6 +164,7 @@ function result(
|
|
|
123
164
|
task: { id: taskId, issueNumber, prNumber },
|
|
124
165
|
pullRequest: null,
|
|
125
166
|
result: null,
|
|
167
|
+
creation: null,
|
|
126
168
|
warnings: [],
|
|
127
169
|
...overrides
|
|
128
170
|
};
|
|
@@ -144,6 +186,14 @@ function externalResult(
|
|
|
144
186
|
};
|
|
145
187
|
}
|
|
146
188
|
|
|
189
|
+
function withCreation(output: PullRequestResult, creation: CreationOutcome): PullRequestResult {
|
|
190
|
+
return { ...output, creation };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const PRECONDITION_NOT_CREATED: CreationOutcome = {
|
|
194
|
+
kind: 'not-created', reason: 'precondition-failed', createdByCurrentOperation: false
|
|
195
|
+
};
|
|
196
|
+
|
|
147
197
|
function normalizePullRequest(remote: RemotePullRequest, repository: string): PullRequestSnapshot | null {
|
|
148
198
|
const number = Number(remote.number);
|
|
149
199
|
const headRepository = remote.head?.repo?.full_name;
|
|
@@ -199,9 +249,17 @@ function repositoryKey(repository: string): string {
|
|
|
199
249
|
return repository.trim().toLowerCase();
|
|
200
250
|
}
|
|
201
251
|
|
|
252
|
+
function isUsableResourceIdentity(identity: ResourceIdentity): boolean {
|
|
253
|
+
return isResourceIdentity(identity);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function identityLabel(identity: ResourceIdentity): string {
|
|
257
|
+
return identity.kind === 'number' ? `PR #${identity.value}` : `PR ${identity.kind}:${identity.value}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
202
260
|
function identitySignature(pullRequest: PullRequestSnapshot): string {
|
|
203
261
|
return [
|
|
204
|
-
pullRequest.repository, pullRequest
|
|
262
|
+
pullRequest.repository, JSON.stringify(pullRequestIdentity(pullRequest)), pullRequest.nodeId, pullRequest.url,
|
|
205
263
|
pullRequest.head.repository, pullRequest.head.ref, pullRequest.head.sha,
|
|
206
264
|
pullRequest.base.repository, pullRequest.base.ref, pullRequest.base.sha,
|
|
207
265
|
pullRequest.mergedAt || '', pullRequest.mergeCommitSha || ''
|
|
@@ -214,54 +272,59 @@ function hasCompleteExternalIdentity(pullRequest: PullRequestSnapshot): boolean
|
|
|
214
272
|
pullRequest.head.repository, pullRequest.head.ref, pullRequest.head.sha,
|
|
215
273
|
pullRequest.base.repository, pullRequest.base.ref, pullRequest.base.sha
|
|
216
274
|
];
|
|
217
|
-
return
|
|
218
|
-
|
|
219
|
-
Boolean(pullRequest.mergedAt) === Boolean(pullRequest.mergeCommitSha);
|
|
275
|
+
return isUsableResourceIdentity(pullRequestIdentity(pullRequest)) && required.every((value) => Boolean(value?.trim())) &&
|
|
276
|
+
(pullRequest.state === 'open' || Boolean(pullRequest.mergedAt) === Boolean(pullRequest.mergeCommitSha));
|
|
220
277
|
}
|
|
221
278
|
|
|
222
279
|
function selectExternalPullRequest(
|
|
223
280
|
candidates: PullRequestSnapshot[],
|
|
224
281
|
repository: string,
|
|
225
|
-
|
|
226
|
-
|
|
282
|
+
existingPrIdentity: ResourceIdentity | number | null,
|
|
283
|
+
explicitPrIdentity: ResourceIdentity | number | null
|
|
227
284
|
): ExternalPullRequestSelection {
|
|
285
|
+
const existingIdentity = typeof existingPrIdentity === 'number'
|
|
286
|
+
? resourceIdentity(existingPrIdentity) : existingPrIdentity;
|
|
287
|
+
const explicitIdentity = typeof explicitPrIdentity === 'number'
|
|
288
|
+
? resourceIdentity(explicitPrIdentity) : explicitPrIdentity;
|
|
228
289
|
const invalid = candidates.find((candidate) => !hasCompleteExternalIdentity(candidate));
|
|
229
290
|
if (invalid) return {
|
|
230
291
|
status: 'failed', code: 'PR_IDENTITY_INVALID',
|
|
231
292
|
message: `Closing PR #${invalid.number} lacks required identity`, candidates, eligible: []
|
|
232
293
|
};
|
|
233
|
-
const
|
|
294
|
+
const byIdentity = new Map<string, string>();
|
|
234
295
|
for (const candidate of candidates) {
|
|
235
296
|
const signature = identitySignature(candidate);
|
|
236
|
-
const
|
|
297
|
+
const identityKey = JSON.stringify(pullRequestIdentity(candidate));
|
|
298
|
+
const prior = byIdentity.get(identityKey);
|
|
237
299
|
if (prior && prior !== signature) return {
|
|
238
300
|
status: 'failed', code: 'PR_IDENTITY_INVALID',
|
|
239
301
|
message: `Closing PR #${candidate.number} has conflicting identities`, candidates, eligible: []
|
|
240
302
|
};
|
|
241
|
-
|
|
303
|
+
byIdentity.set(identityKey, signature);
|
|
242
304
|
}
|
|
243
305
|
const unique = candidates.filter((candidate, index) =>
|
|
244
|
-
candidates.findIndex((other) => other
|
|
306
|
+
candidates.findIndex((other) => resourceIdentityEquals(pullRequestIdentity(other), pullRequestIdentity(candidate))) === index
|
|
245
307
|
);
|
|
246
308
|
const wantedRepository = repositoryKey(repository);
|
|
247
309
|
const eligible = unique.filter((candidate) =>
|
|
248
310
|
repositoryKey(candidate.repository) === wantedRepository &&
|
|
249
311
|
repositoryKey(candidate.base.repository) === wantedRepository &&
|
|
312
|
+
candidate.state === 'closed' &&
|
|
250
313
|
Boolean(candidate.mergedAt && candidate.mergeCommitSha)
|
|
251
314
|
);
|
|
252
|
-
if (
|
|
253
|
-
const selected = unique.find((candidate) => candidate
|
|
254
|
-
if (!selected) return { status: 'failed', code: 'PR_NOT_FOUND', message: `PR
|
|
255
|
-
if (!eligible.some((candidate) => candidate
|
|
256
|
-
return { status: 'failed', code: 'PR_IDENTITY_INVALID', message: `PR
|
|
315
|
+
if (explicitIdentity !== null) {
|
|
316
|
+
const selected = unique.find((candidate) => resourceIdentityEquals(pullRequestIdentity(candidate), explicitIdentity));
|
|
317
|
+
if (!selected) return { status: 'failed', code: 'PR_NOT_FOUND', message: `PR ${identityLabel(explicitIdentity)} is not a closing PR`, candidates, eligible };
|
|
318
|
+
if (!eligible.some((candidate) => resourceIdentityEquals(pullRequestIdentity(candidate), explicitIdentity))) {
|
|
319
|
+
return { status: 'failed', code: 'PR_IDENTITY_INVALID', message: `PR ${identityLabel(explicitIdentity)} is not an eligible merged PR`, candidates, eligible };
|
|
257
320
|
}
|
|
258
|
-
if (
|
|
259
|
-
return { status: 'failed', code: 'PR_BIND_CONFLICT', message: `Task is already bound to
|
|
321
|
+
if (existingIdentity !== null && !resourceIdentityEquals(existingIdentity, explicitIdentity)) {
|
|
322
|
+
return { status: 'failed', code: 'PR_BIND_CONFLICT', message: `Task is already bound to ${identityLabel(existingIdentity)}`, candidates, eligible };
|
|
260
323
|
}
|
|
261
324
|
return { status: 'selected', source: 'explicit', selected, candidates, eligible };
|
|
262
325
|
}
|
|
263
|
-
if (
|
|
264
|
-
return { status: 'failed', code: 'PR_BIND_CONFLICT', message: `Task is already bound to
|
|
326
|
+
if (existingIdentity !== null && eligible.length === 1 && !resourceIdentityEquals(existingIdentity, pullRequestIdentity(eligible[0]!))) {
|
|
327
|
+
return { status: 'failed', code: 'PR_BIND_CONFLICT', message: `Task is already bound to ${identityLabel(existingIdentity)}`, candidates, eligible };
|
|
265
328
|
}
|
|
266
329
|
if (eligible.length === 0) return { status: 'normal', candidates, eligible: [] };
|
|
267
330
|
if (eligible.length > 1) return {
|
|
@@ -340,64 +403,8 @@ function selectPullRequest(remotes: RemotePullRequest[], repository: string, hea
|
|
|
340
403
|
return { status: matches.length === 0 ? 'missing' : 'ambiguous', pullRequest: null };
|
|
341
404
|
}
|
|
342
405
|
|
|
343
|
-
function repositoryHead(cwd: string): string {
|
|
344
|
-
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
345
|
-
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
|
|
346
|
-
}).trim();
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function remoteBranchHead(cwd: string, repository: string, head: string):
|
|
350
|
-
| { ok: true; value: string }
|
|
351
|
-
| { ok: false; error: { code: string; message: string; retryable: boolean } } {
|
|
352
|
-
const wanted = expectedHead(repository, head);
|
|
353
|
-
if (!wanted.ref || !/^[A-Za-z0-9._/-]+$/.test(wanted.ref)) return {
|
|
354
|
-
ok: false,
|
|
355
|
-
error: { code: 'PR_HEAD_INVALID', message: 'Pull request head ref is invalid', retryable: false }
|
|
356
|
-
};
|
|
357
|
-
const remotes = configuredGitRemotes(cwd);
|
|
358
|
-
const remote = remotes.find((item) => repositoryKey(item.repository) === repositoryKey(wanted.repository))
|
|
359
|
-
?? remotes.find((item) => item.name === 'origin');
|
|
360
|
-
if (!remote) return {
|
|
361
|
-
ok: false,
|
|
362
|
-
error: { code: 'PR_REMOTE_BRANCH_MISSING', message: `No configured remote can verify ${wanted.repository}:${wanted.ref}`, retryable: false }
|
|
363
|
-
};
|
|
364
|
-
try {
|
|
365
|
-
const output = execFileSync('git', ['ls-remote', '--refs', remote.name, `refs/heads/${wanted.ref}`], {
|
|
366
|
-
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
|
|
367
|
-
}).trim();
|
|
368
|
-
const match = output.split(/\r?\n/).map((line) => line.trim()).find((line) => line.endsWith(`refs/heads/${wanted.ref}`));
|
|
369
|
-
const sha = match?.split(/\s+/)[0] || null;
|
|
370
|
-
if (!sha || !/^[a-f0-9]{40}$/i.test(sha)) return {
|
|
371
|
-
ok: false,
|
|
372
|
-
error: { code: 'PR_REMOTE_BRANCH_MISSING', message: `Remote branch ${wanted.repository}:${wanted.ref} does not exist`, retryable: false }
|
|
373
|
-
};
|
|
374
|
-
return { ok: true, value: sha };
|
|
375
|
-
} catch (error) {
|
|
376
|
-
return {
|
|
377
|
-
ok: false,
|
|
378
|
-
error: { code: 'PR_REMOTE_BRANCH_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: true }
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function verifyCreateHead(base: ReturnType<typeof resolvedContext> & { ok: true }, head: string) {
|
|
384
|
-
let localHead: string;
|
|
385
|
-
try {
|
|
386
|
-
localHead = repositoryHead(base.resolved.repoRoot);
|
|
387
|
-
} catch (error) {
|
|
388
|
-
return { ok: false as const, error: { code: 'PR_LOCAL_HEAD_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: false } };
|
|
389
|
-
}
|
|
390
|
-
const remote = remoteBranchHead(base.resolved.repoRoot, base.context.platform.repository!, head);
|
|
391
|
-
if (!remote.ok) return remote;
|
|
392
|
-
if (remote.value !== localHead) return {
|
|
393
|
-
ok: false as const,
|
|
394
|
-
error: { code: 'PR_REMOTE_HEAD_MISMATCH', message: `Remote head ${remote.value} does not match local HEAD ${localHead}`, retryable: false }
|
|
395
|
-
};
|
|
396
|
-
return { ok: true as const, value: localHead };
|
|
397
|
-
}
|
|
398
|
-
|
|
399
406
|
function validateBoundPullRequest(
|
|
400
|
-
base: ReturnType<typeof resolvedContext
|
|
407
|
+
base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
|
|
401
408
|
pullRequest: PullRequestSnapshot | null,
|
|
402
409
|
head: string,
|
|
403
410
|
baseRef: string,
|
|
@@ -421,24 +428,187 @@ function validateBoundPullRequest(
|
|
|
421
428
|
return { ok: true };
|
|
422
429
|
}
|
|
423
430
|
|
|
424
|
-
|
|
431
|
+
type BindingIdentityOptions = {
|
|
432
|
+
expectedHead?: string;
|
|
433
|
+
expectedHeadSha?: string;
|
|
434
|
+
errorCode?: string;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
function resolveTaskDeliveryTarget(repoRoot: string, frontmatter: Record<string, unknown>) {
|
|
438
|
+
const existing = {
|
|
439
|
+
...(typeof frontmatter.delivery_remote === 'string' && frontmatter.delivery_remote
|
|
440
|
+
? { remote: frontmatter.delivery_remote } : {}),
|
|
441
|
+
...(typeof frontmatter.delivery_base_ref === 'string' && frontmatter.delivery_base_ref
|
|
442
|
+
? { baseRef: frontmatter.delivery_base_ref } : {})
|
|
443
|
+
};
|
|
444
|
+
const target = resolveDeliveryTarget(repoRoot, existing);
|
|
445
|
+
return target.ok
|
|
446
|
+
? { ok: true as const, value: target.value }
|
|
447
|
+
: { ok: false as const, error: { code: target.code, message: target.message, retryable: false } };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function taskDeliveryTarget(base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true }) {
|
|
451
|
+
return resolveTaskDeliveryTarget(base.resolved.repoRoot, base.frontmatter);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function validateIdentityAgainstTarget(
|
|
455
|
+
repository: string,
|
|
456
|
+
target: { remote: string; baseRef: string },
|
|
457
|
+
pullRequest: PullRequestSnapshot | null,
|
|
458
|
+
options: BindingIdentityOptions = {}
|
|
459
|
+
): { ok: true } | { ok: false; error: { code: string; message: string; retryable: boolean } } {
|
|
460
|
+
if (!pullRequest || !hasCompleteExternalIdentity(pullRequest)) return {
|
|
461
|
+
ok: false,
|
|
462
|
+
error: { code: options.errorCode || 'PR_BIND_IDENTITY_MISMATCH', message: 'Pull request identity is incomplete', retryable: false }
|
|
463
|
+
};
|
|
464
|
+
const wantedHead = options.expectedHead ? expectedHead(repository, options.expectedHead) : null;
|
|
465
|
+
const matches = repositoryKey(pullRequest.repository) === repositoryKey(repository)
|
|
466
|
+
&& repositoryKey(pullRequest.base.repository) === repositoryKey(repository)
|
|
467
|
+
&& pullRequest.base.ref === target.baseRef
|
|
468
|
+
&& (!wantedHead || (repositoryKey(pullRequest.head.repository) === repositoryKey(wantedHead.repository) && pullRequest.head.ref === wantedHead.ref))
|
|
469
|
+
&& (!options.expectedHeadSha || pullRequest.head.sha === options.expectedHeadSha);
|
|
470
|
+
if (!matches) return {
|
|
471
|
+
ok: false,
|
|
472
|
+
error: {
|
|
473
|
+
code: options.errorCode || 'PR_BIND_IDENTITY_MISMATCH',
|
|
474
|
+
message: 'Pull request identity does not match the task branch and delivery target',
|
|
475
|
+
retryable: false
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
return { ok: true };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function validateWriterIdentity(
|
|
482
|
+
base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
|
|
483
|
+
pullRequest: PullRequestSnapshot | null,
|
|
484
|
+
options: BindingIdentityOptions = {}
|
|
485
|
+
): { ok: true } | { ok: false; error: { code: string; message: string; retryable: boolean } } {
|
|
486
|
+
const target = taskDeliveryTarget(base);
|
|
487
|
+
if (!target.ok) return target;
|
|
488
|
+
return validateIdentityAgainstTarget(base.context.platform.repository!, target.value, pullRequest, options);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function sameDeliveryIdentity(left: PullRequestSnapshot, right: PullRequestSnapshot): boolean {
|
|
492
|
+
return JSON.stringify(deliveryIdentity(left)) === JSON.stringify(deliveryIdentity(right));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function validateExternalMergedEvidence(
|
|
496
|
+
initial: PullRequestSnapshot,
|
|
497
|
+
rechecked: PullRequestSnapshot
|
|
498
|
+
): { ok: true } | { ok: false; error: { code: string; message: string; retryable: boolean } } {
|
|
499
|
+
if (
|
|
500
|
+
initial.state !== 'closed'
|
|
501
|
+
|| !initial.mergedAt
|
|
502
|
+
|| !initial.mergeCommitSha
|
|
503
|
+
|| rechecked.state !== 'closed'
|
|
504
|
+
|| !rechecked.mergedAt
|
|
505
|
+
|| !rechecked.mergeCommitSha
|
|
506
|
+
|| initial.mergedAt !== rechecked.mergedAt
|
|
507
|
+
|| initial.mergeCommitSha !== rechecked.mergeCommitSha
|
|
508
|
+
) return {
|
|
509
|
+
ok: false,
|
|
510
|
+
error: { code: 'PR_EXTERNAL_IDENTITY_MISMATCH', message: 'Selected pull request merged evidence changed before task binding', retryable: false }
|
|
511
|
+
};
|
|
512
|
+
return { ok: true };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function resolvedContext(taskRef: string, options: InspectionOptions) {
|
|
425
516
|
const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
|
|
426
517
|
if (!resolved.ok) return { ok: false as const, output: result('failed', resolved.taskId, null, null, {
|
|
427
518
|
error: { code: resolved.code, message: resolved.message, retryable: false }
|
|
428
519
|
}) };
|
|
429
520
|
const content = fs.readFileSync(resolved.taskMdPath, 'utf8');
|
|
430
|
-
const frontmatter =
|
|
431
|
-
const
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
521
|
+
const frontmatter = parseTypedTaskFrontmatter(content);
|
|
522
|
+
const factRead = readPrDeliveryFact(frontmatter, options.runtimeVersion);
|
|
523
|
+
if (factRead.status === 'invalid') return { ok: false as const, output: result('failed', resolved.taskId, null, null, {
|
|
524
|
+
error: { code: factRead.error.code, message: factRead.error.message, retryable: false }
|
|
525
|
+
}) };
|
|
526
|
+
const fact = factRead.status === 'valid' ? factRead.fact : null;
|
|
527
|
+
let issueIdentity: ResourceIdentity | null;
|
|
528
|
+
try { issueIdentity = taskIssueIdentity(frontmatter, undefined, options.runtimeVersion); }
|
|
529
|
+
catch (error) { return { ok: false as const, output: result('failed', resolved.taskId, null, null, { error: { ...taskIssueIdentityError(error), retryable: false } }) }; }
|
|
530
|
+
const issueNumber = resourceIdentityNumber(issueIdentity);
|
|
531
|
+
const prIdentity = fact?.state === 'bound' ? fact.identity.resource : null;
|
|
532
|
+
const prNumber = resourceIdentityNumber(prIdentity);
|
|
533
|
+
const client = options.client;
|
|
534
|
+
const loaded = await resolvePlatformProviderContext({ cwd: resolved.repoRoot, client });
|
|
535
|
+
const context = loaded.ok ? loaded.value.context : loaded.context;
|
|
437
536
|
const usable = (context.status === 'no-op' || context.status === 'degraded') && context.platform.repository;
|
|
438
|
-
if (!usable) return { ok: false as const, output: result(context.status, resolved.taskId, issueNumber, prNumber, {
|
|
537
|
+
if (!usable || !loaded.ok) return { ok: false as const, output: result(context.status, resolved.taskId, issueNumber, prNumber, {
|
|
439
538
|
platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
|
|
440
539
|
}) };
|
|
441
|
-
return { ok: true as const, resolved, content, frontmatter, issueNumber, prNumber, client, context };
|
|
540
|
+
return { ok: true as const, resolved, content, frontmatter, fact, issueIdentity, issueNumber, prIdentity, prNumber, client, context, provider: loaded.value.provider, providerType: loaded.value.providerType, loadedContext: loaded.value };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function normalizeProviderPullRequest(
|
|
544
|
+
remote: ProviderChangeRequestSnapshot,
|
|
545
|
+
repository: string,
|
|
546
|
+
fallbackNumber: number,
|
|
547
|
+
fallback?: PullRequestSnapshot | null
|
|
548
|
+
): PullRequestSnapshot {
|
|
549
|
+
const head = remote.head || fallback?.head || { repository, ref: '', sha: remote.headSha || '' };
|
|
550
|
+
const base = remote.base || fallback?.base || { repository, ref: '', sha: remote.baseSha || '' };
|
|
551
|
+
const identity = remote.identity || fallback?.identity || (remote.number ? { kind: 'number' as const, value: remote.number } : { kind: 'id' as const, value: remote.id });
|
|
552
|
+
const snapshot: PullRequestSnapshot = {
|
|
553
|
+
repository,
|
|
554
|
+
number: remote.number ?? resourceIdentityNumber(identity) ?? fallbackNumber,
|
|
555
|
+
nodeId: remote.id,
|
|
556
|
+
url: remote.displayUrl || fallback?.url || '',
|
|
557
|
+
state: remote.state === 'closed' ? 'closed' : 'open',
|
|
558
|
+
title: remote.title,
|
|
559
|
+
body: remote.body,
|
|
560
|
+
draft: remote.draft ?? fallback?.draft ?? false,
|
|
561
|
+
head: { ...head, sha: remote.headSha || head.sha },
|
|
562
|
+
base: { ...base, sha: remote.baseSha || base.sha },
|
|
563
|
+
mergedAt: remote.mergedAt ?? fallback?.mergedAt ?? null,
|
|
564
|
+
mergeCommitSha: remote.mergeCommitSha ?? fallback?.mergeCommitSha ?? null,
|
|
565
|
+
labels: [...(remote.labels || fallback?.labels || [])].sort(),
|
|
566
|
+
assignees: [...(remote.assignees || fallback?.assignees || [])].sort(),
|
|
567
|
+
milestone: remote.milestone ?? fallback?.milestone ?? null,
|
|
568
|
+
mergeability: remote.mergeability || fallback?.mergeability || { state: 'unknown', detail: null }
|
|
569
|
+
};
|
|
570
|
+
Object.defineProperty(snapshot, 'identity', { value: identity, enumerable: false, configurable: true });
|
|
571
|
+
return snapshot;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function pullRequestIdentity(pullRequest: PullRequestSnapshot): ResourceIdentity {
|
|
575
|
+
return pullRequest.identity || { kind: 'number', value: pullRequest.number };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function providerPullRequestError(
|
|
579
|
+
base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
|
|
580
|
+
error: { code: string; message: string; retryable: boolean },
|
|
581
|
+
prNumber: number | null
|
|
582
|
+
): PullRequestResult {
|
|
583
|
+
return result(error.code.startsWith('IN_LABEL_SYNC') ? 'blocked' : providerStatus(error), base.resolved.taskId, base.issueNumber, prNumber, {
|
|
584
|
+
changed: error.code.startsWith('IN_LABEL_SYNC'),
|
|
585
|
+
platform: base.context.platform,
|
|
586
|
+
capabilities: base.context.capabilities,
|
|
587
|
+
resource: { kind: 'pull-request', number: prNumber },
|
|
588
|
+
error: providerError(error, 'PLATFORM_PROVIDER_OPERATION_FAILED')
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function inspectExternalPullRequest(
|
|
593
|
+
base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
|
|
594
|
+
prIdentity: ResourceIdentity
|
|
595
|
+
): Promise<PullRequestResult> {
|
|
596
|
+
const prNumber = resourceIdentityNumber(prIdentity);
|
|
597
|
+
const fallback = base.fact?.state === 'bound' && resourceIdentityEquals(base.fact.identity.resource, prIdentity)
|
|
598
|
+
? base.fact.identity as unknown as PullRequestSnapshot
|
|
599
|
+
: null;
|
|
600
|
+
const inspected = base.provider.changeRequests?.inspect
|
|
601
|
+
? await base.provider.changeRequests.inspect({ context: providerOperationContext(base.loadedContext), target: prIdentity })
|
|
602
|
+
: unsupportedProviderOperation(base.provider, 'changeRequests.inspect');
|
|
603
|
+
if (!inspected.ok) return providerPullRequestError(base, inspected.error, prNumber);
|
|
604
|
+
const pullRequest = normalizeProviderPullRequest(inspected.value, base.context.platform.repository!, prNumber || 0, fallback);
|
|
605
|
+
return result('no-op', base.resolved.taskId, base.issueNumber, prNumber, {
|
|
606
|
+
platform: base.context.platform,
|
|
607
|
+
capabilities: base.context.capabilities,
|
|
608
|
+
resource: { kind: 'pull-request', number: prNumber, identity: prIdentity },
|
|
609
|
+
pullRequest,
|
|
610
|
+
error: null
|
|
611
|
+
});
|
|
442
612
|
}
|
|
443
613
|
|
|
444
614
|
function inspectGitHubPullRequest(client: GitHubClient, repository: string, number: number, cwd: string) {
|
|
@@ -450,200 +620,348 @@ function inspectGitHubPullRequest(client: GitHubClient, repository: string, numb
|
|
|
450
620
|
: { ok: false as const, error: { code: 'PR_IDENTITY_INVALID', message: 'Remote resource is not a valid pull request', retryable: false } };
|
|
451
621
|
}
|
|
452
622
|
|
|
453
|
-
function
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
623
|
+
async function inspectPlatformPullRequest(taskRef: string, options: InspectionOptions = {}): Promise<PullRequestResult> {
|
|
624
|
+
const base = await resolvedContext(taskRef, options);
|
|
625
|
+
if (!base.ok) return base.output;
|
|
626
|
+
if (!base.fact) return result('no-op', base.resolved.taskId, base.issueNumber, null, {
|
|
627
|
+
error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
|
|
628
|
+
});
|
|
629
|
+
if (!base.prIdentity) return result('no-op', base.resolved.taskId, base.issueNumber, null, {
|
|
630
|
+
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
631
|
+
error: { code: 'PR_NOT_LINKED', message: 'Task has no verified bound pull request', retryable: false }
|
|
632
|
+
});
|
|
633
|
+
return inspectExternalPullRequest(base, base.prIdentity);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function inspectPlatformPullRequestByNumber(prNumber: string | number, options: InspectionOptions = {}): Promise<PullRequestResult> {
|
|
637
|
+
const loaded = await resolvePlatformProviderContext({ cwd: options.cwd || process.cwd(), client: options.client });
|
|
638
|
+
const context = loaded.ok ? loaded.value.context : loaded.context;
|
|
639
|
+
const usable = (context.status === 'no-op' || context.status === 'degraded') && context.platform.repository;
|
|
640
|
+
if (!usable) {
|
|
641
|
+
return result(context.status, null, null, null, {
|
|
642
|
+
platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
let identity: ResourceIdentity;
|
|
646
|
+
try { identity = loaded.ok ? providerResourceToken(loaded.value.provider, 'pull-request', String(prNumber)) : resourceIdentity(prNumber); }
|
|
647
|
+
catch (error) {
|
|
648
|
+
return result('failed', null, null, null, {
|
|
649
|
+
platform: context.platform, capabilities: context.capabilities,
|
|
650
|
+
error: { code: 'PLATFORM_IDENTITY_TOKEN_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
if (loaded.ok) {
|
|
654
|
+
const inspected = loaded.value.provider.changeRequests?.inspect
|
|
655
|
+
? await loaded.value.provider.changeRequests.inspect({ context: providerOperationContext(loaded.value), target: identity })
|
|
656
|
+
: unsupportedProviderOperation(loaded.value.provider, 'changeRequests.inspect');
|
|
657
|
+
const number = resourceIdentityNumber(identity);
|
|
658
|
+
if (!inspected.ok) return result(providerStatus(inspected.error), null, null, number, {
|
|
659
|
+
platform: context.platform, capabilities: context.capabilities,
|
|
660
|
+
resource: { kind: 'pull-request', number, identity }, error: providerError(inspected.error, 'PLATFORM_PROVIDER_OPERATION_FAILED')
|
|
661
|
+
});
|
|
662
|
+
return result('no-op', null, null, number, {
|
|
663
|
+
platform: context.platform, capabilities: context.capabilities,
|
|
664
|
+
resource: { kind: 'pull-request', number, identity },
|
|
665
|
+
pullRequest: normalizeProviderPullRequest(inspected.value, context.platform.repository!, number || 0), error: null
|
|
467
666
|
});
|
|
468
|
-
} catch {
|
|
469
|
-
return [];
|
|
470
667
|
}
|
|
668
|
+
return result('failed', null, null, null, {
|
|
669
|
+
platform: context.platform, capabilities: context.capabilities,
|
|
670
|
+
resource: { kind: 'pull-request', number: null },
|
|
671
|
+
error: { code: 'PLATFORM_PROVIDER_LOAD_FAILED', message: 'Selected provider failed to load', retryable: false }
|
|
672
|
+
});
|
|
471
673
|
}
|
|
472
674
|
|
|
473
|
-
|
|
474
|
-
const match = remote.trim().match(
|
|
475
|
-
/^(https?:\/\/github\.com\/|ssh:\/\/(?:git@)?github\.com\/|git@github\.com:)[^/\s]+\/[^/\s]+(?:\.git)?$/i
|
|
476
|
-
);
|
|
477
|
-
return match ? `${match[1]}${repository}.git` : null;
|
|
478
|
-
}
|
|
675
|
+
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}}}}}';
|
|
479
676
|
|
|
480
|
-
function
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}) {
|
|
489
|
-
if (
|
|
490
|
-
pullRequest.repository !== repository ||
|
|
491
|
-
pullRequest.base.repository !== repository ||
|
|
492
|
-
pullRequest.number <= 0
|
|
493
|
-
) {
|
|
677
|
+
function readInLabelMapping(repoRoot: string): { ok: true; value: Record<string, unknown> } | { ok: false; error: { code: string; message: string; retryable: boolean } } {
|
|
678
|
+
try {
|
|
679
|
+
const config = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8')) as {
|
|
680
|
+
labels?: { in?: unknown };
|
|
681
|
+
};
|
|
682
|
+
const mapping = validateInLabelMapping(config.labels?.in);
|
|
683
|
+
return mapping.ok ? { ok: true, value: mapping.value } : mapping;
|
|
684
|
+
} catch (error) {
|
|
494
685
|
return {
|
|
495
|
-
ok: false
|
|
496
|
-
error: {
|
|
497
|
-
code: 'PR_MERGE_EVIDENCE_SOURCE_UNAVAILABLE',
|
|
498
|
-
message: 'Pull request repository identity is inconsistent',
|
|
499
|
-
retryable: false
|
|
500
|
-
}
|
|
686
|
+
ok: false,
|
|
687
|
+
error: { code: 'IN_LABEL_SYNC_CONFIG_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
|
|
501
688
|
};
|
|
502
689
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function taskInLabelTarget(
|
|
693
|
+
repoRoot: string,
|
|
694
|
+
frontmatter: Record<string, unknown>,
|
|
695
|
+
currentLabels: readonly string[],
|
|
696
|
+
repositoryLabels: Set<string>
|
|
697
|
+
) {
|
|
698
|
+
const baseRef = typeof frontmatter.delivery_base_ref === 'string' ? frontmatter.delivery_base_ref.trim() : '';
|
|
699
|
+
if (!baseRef) return {
|
|
700
|
+
ok: false as const,
|
|
701
|
+
error: { code: 'IN_LABEL_SYNC_BASE_MISSING', message: 'Task has no delivery_base_ref for in-label evidence', retryable: false }
|
|
514
702
|
};
|
|
515
|
-
|
|
703
|
+
let changedFiles: string[];
|
|
704
|
+
try {
|
|
705
|
+
changedFiles = execFileSync('git', ['diff', `${baseRef}...HEAD`, '--name-only'], {
|
|
706
|
+
cwd: repoRoot, encoding: 'utf8'
|
|
707
|
+
}).trim().split(/\r?\n/).filter(Boolean);
|
|
708
|
+
} catch (error) {
|
|
516
709
|
return {
|
|
517
710
|
ok: false as const,
|
|
518
|
-
error: {
|
|
519
|
-
code: 'PR_MERGE_EVIDENCE_SOURCE_UNAVAILABLE',
|
|
520
|
-
message: 'Pull request Git refs are invalid',
|
|
521
|
-
retryable: false
|
|
522
|
-
}
|
|
711
|
+
error: { code: 'IN_LABEL_SYNC_EVIDENCE_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: false }
|
|
523
712
|
};
|
|
524
713
|
}
|
|
714
|
+
const mapping = readInLabelMapping(repoRoot);
|
|
715
|
+
if (!mapping.ok) return mapping;
|
|
716
|
+
const planned = planInLabelUpdate({ changedFiles, currentLabels, mapping: mapping.value, repositoryLabels });
|
|
717
|
+
if (planned.error) return { ok: false as const, error: planned.error };
|
|
718
|
+
return { ok: true as const, value: planned.target };
|
|
719
|
+
}
|
|
525
720
|
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
721
|
+
function inspectClosingIssueNumbers(client: GitHubClient, repository: string, pullRequest: number, cwd: string) {
|
|
722
|
+
const [owner, name] = repository.split('/');
|
|
723
|
+
if (!owner || !name) return {
|
|
724
|
+
ok: false as const,
|
|
725
|
+
error: { code: 'IN_LABEL_SYNC_REPOSITORY_INVALID', message: 'Repository identity is invalid', retryable: false }
|
|
726
|
+
};
|
|
727
|
+
let cursor: string | null = null;
|
|
728
|
+
const numbers: number[] = [];
|
|
729
|
+
for (;;) {
|
|
730
|
+
const args = ['api', 'graphql', '-f', `query=${CLOSING_ISSUES_QUERY}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `number=${pullRequest}`];
|
|
731
|
+
if (cursor) args.push('-F', `cursor=${cursor}`);
|
|
732
|
+
const response = client.json<unknown>(args, { cwd });
|
|
733
|
+
if (!response.ok) return response;
|
|
734
|
+
const connection = (response.value as {
|
|
735
|
+
data?: { repository?: { pullRequest?: { closingIssuesReferences?: {
|
|
736
|
+
nodes?: unknown[]; pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
|
|
737
|
+
} } } };
|
|
738
|
+
})?.data?.repository?.pullRequest?.closingIssuesReferences;
|
|
739
|
+
if (!connection || !Array.isArray(connection.nodes) || !connection.pageInfo) return {
|
|
535
740
|
ok: false as const,
|
|
536
|
-
error: {
|
|
537
|
-
code: 'PR_MERGE_EVIDENCE_SOURCE_UNAVAILABLE',
|
|
538
|
-
message: `No GitHub remote can provide evidence for ${repository}`,
|
|
539
|
-
retryable: false
|
|
540
|
-
}
|
|
741
|
+
error: { code: 'IN_LABEL_SYNC_ASSOCIATION_INVALID', message: 'Closing Issue response is incomplete', retryable: false }
|
|
541
742
|
};
|
|
743
|
+
for (const node of connection.nodes) {
|
|
744
|
+
const issue = Number((node as { number?: unknown })?.number);
|
|
745
|
+
if (!Number.isSafeInteger(issue) || issue <= 0) return {
|
|
746
|
+
ok: false as const,
|
|
747
|
+
error: { code: 'IN_LABEL_SYNC_ASSOCIATION_INVALID', message: 'Closing Issue identity is invalid', retryable: false }
|
|
748
|
+
};
|
|
749
|
+
numbers.push(issue);
|
|
750
|
+
}
|
|
751
|
+
if (!connection.pageInfo.hasNextPage) return { ok: true as const, value: [...new Set(numbers)] };
|
|
752
|
+
if (!connection.pageInfo.endCursor || connection.pageInfo.endCursor === cursor) return {
|
|
753
|
+
ok: false as const,
|
|
754
|
+
error: { code: 'IN_LABEL_SYNC_ASSOCIATION_INVALID', message: 'Closing Issue pagination cursor is invalid', retryable: false }
|
|
755
|
+
};
|
|
756
|
+
cursor = connection.pageInfo.endCursor;
|
|
542
757
|
}
|
|
543
|
-
return {
|
|
544
|
-
ok: true as const,
|
|
545
|
-
value: { remoteUrl, reviewedHeadRef, targetHeadRef }
|
|
546
|
-
};
|
|
547
758
|
}
|
|
548
759
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
return inspectGitHubIssueClosingChangeRequests(
|
|
555
|
-
(client as GitHubClient | undefined) || createGitHubClient(), repository, issueNumber, cwd
|
|
556
|
-
);
|
|
557
|
-
},
|
|
558
|
-
resolveChangeRequestGitEvidence(context) {
|
|
559
|
-
return resolveGitHubChangeRequestGitEvidence(context);
|
|
560
|
-
}
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
function locatePullRequest(base: ReturnType<typeof resolvedContext> & { ok: true }, head: string, target: string, expectedSha?: string) {
|
|
564
|
-
const repository = base.context.platform.repository!;
|
|
565
|
-
const listed = base.client.json<RemotePullRequest[]>([
|
|
566
|
-
'api', `repos/${repository}/pulls?state=open&base=${encodeURIComponent(target)}&per_page=100`
|
|
567
|
-
], { cwd: base.resolved.repoRoot });
|
|
568
|
-
if (!listed.ok) return listed;
|
|
569
|
-
const selected = selectPullRequest(listed.value, repository, head, target, expectedSha);
|
|
570
|
-
return selected.status === 'resolved'
|
|
571
|
-
? { ok: true as const, value: selected.pullRequest }
|
|
572
|
-
: { ok: false as const, error: {
|
|
573
|
-
code: selected.status === 'ambiguous' ? 'PR_IDENTITY_AMBIGUOUS'
|
|
574
|
-
: selected.status === 'head-mismatch' ? 'PR_HEAD_SHA_MISMATCH' : 'PR_NOT_FOUND',
|
|
575
|
-
message: selected.status === 'ambiguous' ? 'Multiple pull requests match the exact head/base identity'
|
|
576
|
-
: selected.status === 'head-mismatch' ? 'A pull request matches head/base but not the expected head SHA'
|
|
577
|
-
: 'No pull request matches the exact head/base identity',
|
|
578
|
-
retryable: false
|
|
579
|
-
} };
|
|
760
|
+
function inspectRepositoryLabels(client: GitHubClient, repository: string, cwd: string) {
|
|
761
|
+
const response = client.json<unknown>(['api', '--paginate', '--slurp', `repos/${repository}/labels?per_page=100`], { cwd });
|
|
762
|
+
if (!response.ok) return response;
|
|
763
|
+
const labels = validateRepositoryLabelPayload(response.value);
|
|
764
|
+
return labels.ok ? { ok: true as const, value: new Set(labels.value) } : labels;
|
|
580
765
|
}
|
|
581
766
|
|
|
582
|
-
function
|
|
583
|
-
const
|
|
584
|
-
if (!
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
767
|
+
function inspectPullRequestFiles(client: GitHubClient, repository: string, pullRequest: number, cwd: string) {
|
|
768
|
+
const response = client.json<unknown>(['api', '--paginate', '--slurp', `repos/${repository}/pulls/${pullRequest}/files?per_page=100`], { cwd });
|
|
769
|
+
if (!response.ok) return response;
|
|
770
|
+
const files = extractPullRequestFileNames(response.value);
|
|
771
|
+
return files ? { ok: true as const, value: files } : {
|
|
772
|
+
ok: false as const,
|
|
773
|
+
error: { code: 'IN_LABEL_SYNC_FILES_INVALID', message: 'Pull request files response is incomplete', retryable: false }
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function inspectTaskInLabelTarget(
|
|
778
|
+
repoRoot: string,
|
|
779
|
+
frontmatter: Record<string, unknown>,
|
|
780
|
+
pullRequest: PullRequestSnapshot,
|
|
781
|
+
repository: string,
|
|
782
|
+
client: GitHubClient
|
|
783
|
+
) {
|
|
784
|
+
const baseRef = typeof frontmatter.delivery_base_ref === 'string' ? frontmatter.delivery_base_ref.trim() : '';
|
|
785
|
+
if (!baseRef) return {
|
|
786
|
+
ok: false as const,
|
|
787
|
+
error: { code: 'IN_LABEL_SYNC_BASE_MISSING', message: 'Task has no delivery_base_ref for in-label evidence', retryable: false }
|
|
788
|
+
};
|
|
789
|
+
let changedFiles: string[];
|
|
790
|
+
try {
|
|
791
|
+
changedFiles = execFileSync('git', ['diff', `${baseRef}...HEAD`, '--name-only'], {
|
|
792
|
+
cwd: repoRoot, encoding: 'utf8'
|
|
793
|
+
}).trim().split(/\r?\n/).filter(Boolean);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
return {
|
|
796
|
+
ok: false as const,
|
|
797
|
+
error: { code: 'IN_LABEL_SYNC_EVIDENCE_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: false }
|
|
600
798
|
};
|
|
601
|
-
return result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
602
|
-
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
603
|
-
resource: { kind: 'pull-request', number: base.prNumber }, error
|
|
604
|
-
});
|
|
605
799
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
800
|
+
const mapping = readInLabelMapping(repoRoot);
|
|
801
|
+
if (!mapping.ok) return mapping;
|
|
802
|
+
const labels = inspectRepositoryLabels(client, repository, repoRoot);
|
|
803
|
+
if (!labels.ok) return labels;
|
|
804
|
+
const planned = planInLabelUpdate({
|
|
805
|
+
changedFiles, currentLabels: pullRequest.labels, mapping: mapping.value, repositoryLabels: labels.value
|
|
609
806
|
});
|
|
807
|
+
if (planned.error) return { ok: false as const, error: planned.error };
|
|
808
|
+
return { ok: true as const, value: { changedFiles, target: planned.target } };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function inLabelResource(
|
|
812
|
+
kind: 'issue' | 'pull-request',
|
|
813
|
+
number: number,
|
|
814
|
+
before: string[],
|
|
815
|
+
expected: string[],
|
|
816
|
+
after: string[] | null,
|
|
817
|
+
effect: 'no-op' | 'applied' | 'unknown'
|
|
818
|
+
) {
|
|
819
|
+
return { kind, number, before: [...before].sort(), expected: [...expected].sort(), after: after ? [...after].sort() : null, effect };
|
|
610
820
|
}
|
|
611
821
|
|
|
612
|
-
function
|
|
613
|
-
const
|
|
614
|
-
const
|
|
822
|
+
async function syncPlatformPullRequestInLabels(prNumber: number, options: SharedOptions & { dryRun?: boolean } = {}): Promise<PullRequestResult> {
|
|
823
|
+
const cwd = path.resolve(options.cwd || process.cwd());
|
|
824
|
+
const client = options.client || createGitHubClient();
|
|
825
|
+
const context = await resolvePlatformContext({ cwd, client });
|
|
615
826
|
const usable = (context.status === 'no-op' || context.status === 'degraded') && context.platform.repository;
|
|
616
|
-
if (!usable) {
|
|
617
|
-
|
|
618
|
-
|
|
827
|
+
if (!usable) return result(context.status, null, null, prNumber, {
|
|
828
|
+
platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
|
|
829
|
+
});
|
|
830
|
+
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) return result('failed', null, null, prNumber, {
|
|
831
|
+
platform: context.platform, capabilities: context.capabilities,
|
|
832
|
+
error: { code: 'PR_NUMBER_INVALID', message: 'PR number must be positive', retryable: false }
|
|
833
|
+
});
|
|
834
|
+
const repository = context.platform.repository!;
|
|
835
|
+
const fetched = inspectGitHubPullRequest(client, repository, prNumber, cwd);
|
|
836
|
+
if (!fetched.ok) return result(fetched.error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
|
|
837
|
+
platform: context.platform, capabilities: context.capabilities, resource: { kind: 'pull-request', number: prNumber }, error: fetched.error
|
|
838
|
+
});
|
|
839
|
+
const files = inspectPullRequestFiles(client, repository, prNumber, cwd);
|
|
840
|
+
if (!files.ok) return result(files.error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
|
|
841
|
+
platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
|
|
842
|
+
resource: { kind: 'pull-request', number: prNumber }, error: files.error
|
|
843
|
+
});
|
|
844
|
+
const mapping = readInLabelMapping(cwd);
|
|
845
|
+
if (!mapping.ok) return result('failed', null, null, prNumber, {
|
|
846
|
+
platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
|
|
847
|
+
resource: { kind: 'pull-request', number: prNumber }, error: mapping.error
|
|
848
|
+
});
|
|
849
|
+
const repositoryLabels = inspectRepositoryLabels(client, repository, cwd);
|
|
850
|
+
if (!repositoryLabels.ok) return result(repositoryLabels.error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
|
|
851
|
+
platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
|
|
852
|
+
resource: { kind: 'pull-request', number: prNumber }, error: repositoryLabels.error
|
|
853
|
+
});
|
|
854
|
+
const plannedTarget = planInLabelUpdate({
|
|
855
|
+
changedFiles: files.value, currentLabels: fetched.value.labels,
|
|
856
|
+
mapping: mapping.value, repositoryLabels: repositoryLabels.value
|
|
857
|
+
});
|
|
858
|
+
if (plannedTarget.error) return result('failed', null, null, prNumber, {
|
|
859
|
+
platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
|
|
860
|
+
resource: { kind: 'pull-request', number: prNumber }, error: plannedTarget.error
|
|
861
|
+
});
|
|
862
|
+
const target = plannedTarget.target;
|
|
863
|
+
const association = inspectClosingIssueNumbers(client, repository, prNumber, cwd);
|
|
864
|
+
if (!association.ok) return result(association.error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
|
|
865
|
+
platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
|
|
866
|
+
resource: { kind: 'pull-request', number: prNumber }, evidence: { kind: 'pull-request-files', pullRequestFiles: files.value }, error: association.error
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
const issueNumber = association.value.length === 1 ? association.value[0]! : null;
|
|
870
|
+
let issue: IssueSnapshot | null = null;
|
|
871
|
+
if (issueNumber) {
|
|
872
|
+
const fetchedIssue = inspectGitHubIssue(client, repository, issueNumber, cwd);
|
|
873
|
+
if (!fetchedIssue.ok) return result(fetchedIssue.error.retryable ? 'blocked' : 'failed', null, issueNumber, prNumber, {
|
|
874
|
+
platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
|
|
875
|
+
resource: { kind: 'issue', number: issueNumber }, evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value }, error: fetchedIssue.error
|
|
619
876
|
});
|
|
877
|
+
issue = fetchedIssue.value;
|
|
620
878
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
879
|
+
|
|
880
|
+
const resources = issue
|
|
881
|
+
? [
|
|
882
|
+
{ kind: 'issue' as const, number: issue.number, snapshot: issue },
|
|
883
|
+
{ kind: 'pull-request' as const, number: prNumber, snapshot: fetched.value }
|
|
884
|
+
]
|
|
885
|
+
: [{ kind: 'pull-request' as const, number: prNumber, snapshot: fetched.value }];
|
|
886
|
+
const plans = resources.map((resource) => ({
|
|
887
|
+
...resource,
|
|
888
|
+
plan: planInLabelUpdate({
|
|
889
|
+
changedFiles: files.value, currentLabels: resource.snapshot.labels,
|
|
890
|
+
mapping: mapping.value, repositoryLabels: repositoryLabels.value
|
|
891
|
+
})
|
|
892
|
+
}));
|
|
893
|
+
const operations: PlatformOperation[] = [
|
|
894
|
+
{ name: 'closing-issues', status: 'no-op', reasonCode: association.value.length === 1 ? null : association.value.length === 0 ? 'ISSUE_ASSOCIATION_NONE' : 'ISSUE_ASSOCIATION_AMBIGUOUS' },
|
|
895
|
+
...plans.map((item) => ({
|
|
896
|
+
name: `labels:in:${item.kind}`,
|
|
897
|
+
status: !context.capabilities.triage ? 'skipped' as const : item.plan.changed ? 'planned' as const : 'no-op' as const,
|
|
898
|
+
reasonCode: !context.capabilities.triage ? 'TRIAGE_REQUIRED' : null
|
|
899
|
+
}))
|
|
900
|
+
];
|
|
901
|
+
const willWrite = context.capabilities.triage && !options.dryRun;
|
|
902
|
+
const resourcesState = plans.map((item) => inLabelResource(
|
|
903
|
+
item.kind,
|
|
904
|
+
item.number,
|
|
905
|
+
item.snapshot.labels,
|
|
906
|
+
target,
|
|
907
|
+
item.plan.changed && willWrite ? null : item.snapshot.labels,
|
|
908
|
+
item.plan.changed && willWrite ? 'unknown' : 'no-op'
|
|
909
|
+
));
|
|
910
|
+
if (options.dryRun || !context.capabilities.triage) return result(
|
|
911
|
+
!context.capabilities.triage || association.value.length !== 1 ? 'degraded' : plans.some((item) => item.plan.changed) ? 'planned' : 'no-op',
|
|
912
|
+
null, issueNumber, prNumber, {
|
|
913
|
+
platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
|
|
914
|
+
resource: { kind: 'pull-request', number: prNumber }, operations, resources: resourcesState,
|
|
915
|
+
evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value }, error: null
|
|
916
|
+
}
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
let currentPullRequest = fetched.value;
|
|
920
|
+
let successfulWrites = 0;
|
|
921
|
+
for (const item of plans) {
|
|
922
|
+
if (!item.plan.changed) continue;
|
|
923
|
+
const synced = syncLabelDelta(client, repository, item.number, cwd, item.snapshot.labels, item.plan.target);
|
|
924
|
+
if (synced.status === 'failed' || synced.status === 'blocked') {
|
|
925
|
+
const state = resourcesState.find((resource) => resource.kind === item.kind && resource.number === item.number)!;
|
|
926
|
+
state.effect = synced.status === 'blocked' ? 'unknown' : 'no-op';
|
|
927
|
+
state.after = synced.status === 'blocked' ? null : item.snapshot.labels;
|
|
928
|
+
const partial = successfulWrites > 0 || synced.status === 'blocked';
|
|
929
|
+
const error = partial && synced.error
|
|
930
|
+
? { ...synced.error, code: 'IN_LABEL_SYNC_PARTIAL', message: `In-label synchronization is partial or unknown: ${synced.error.message}` }
|
|
931
|
+
: synced.error;
|
|
932
|
+
return result(partial ? 'blocked' : synced.status, null, issueNumber, prNumber, {
|
|
933
|
+
changed: successfulWrites > 0 || synced.changed, platform: context.platform, capabilities: context.capabilities,
|
|
934
|
+
pullRequest: currentPullRequest, resource: { kind: 'pull-request', number: prNumber }, operations, resources: resourcesState,
|
|
935
|
+
evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value }, error
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
successfulWrites += synced.changed ? 1 : 0;
|
|
939
|
+
const reread = item.kind === 'issue'
|
|
940
|
+
? inspectGitHubIssue(client, repository, item.number, cwd)
|
|
941
|
+
: inspectGitHubPullRequest(client, repository, item.number, cwd);
|
|
942
|
+
if (!reread.ok) return result('blocked', null, issueNumber, prNumber, {
|
|
943
|
+
changed: true, platform: context.platform, capabilities: context.capabilities,
|
|
944
|
+
pullRequest: currentPullRequest, resource: { kind: 'pull-request', number: prNumber }, operations, resources: resourcesState,
|
|
945
|
+
evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value },
|
|
946
|
+
error: { ...reread.error, code: 'IN_LABEL_SYNC_PARTIAL', message: `In-label synchronization is partial or unknown: ${reread.error.message}` }
|
|
625
947
|
});
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
code: 'PR_INSPECTION_INVALID',
|
|
636
|
-
message: 'Platform adapter returned no change-request snapshot',
|
|
637
|
-
retryable: false
|
|
638
|
-
};
|
|
639
|
-
return result(error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
|
|
640
|
-
platform: context.platform, capabilities: context.capabilities,
|
|
641
|
-
resource: { kind: 'pull-request', number: prNumber }, error
|
|
948
|
+
const after = reread.value.labels;
|
|
949
|
+
const state = resourcesState.find((resource) => resource.kind === item.kind && resource.number === item.number)!;
|
|
950
|
+
state.after = after;
|
|
951
|
+
state.effect = after.filter((label: string) => label.startsWith('in:')).sort().join('\0') === target.join('\0') ? 'applied' : 'unknown';
|
|
952
|
+
if (state.effect === 'unknown') return result('blocked', null, issueNumber, prNumber, {
|
|
953
|
+
changed: true, platform: context.platform, capabilities: context.capabilities,
|
|
954
|
+
pullRequest: currentPullRequest, resource: { kind: 'pull-request', number: prNumber }, operations, resources: resourcesState,
|
|
955
|
+
evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value },
|
|
956
|
+
error: { code: 'IN_LABEL_SYNC_PARTIAL', message: `${item.kind} #${item.number} did not converge to the expected in: labels`, retryable: true }
|
|
642
957
|
});
|
|
958
|
+
if (item.kind === 'pull-request') currentPullRequest = reread.value as PullRequestSnapshot;
|
|
643
959
|
}
|
|
644
|
-
return result('no-op', null,
|
|
645
|
-
platform: context.platform, capabilities: context.capabilities,
|
|
646
|
-
resource: { kind: 'pull-request', number: prNumber },
|
|
960
|
+
return result(association.value.length !== 1 ? 'degraded' : successfulWrites > 0 ? 'applied' : 'no-op', null, issueNumber, prNumber, {
|
|
961
|
+
changed: successfulWrites > 0, platform: context.platform, capabilities: context.capabilities,
|
|
962
|
+
pullRequest: currentPullRequest, resource: { kind: 'pull-request', number: prNumber },
|
|
963
|
+
operations: operations.map((operation) => operation.status === 'planned' ? { ...operation, status: 'applied' as const } : operation),
|
|
964
|
+
resources: resourcesState, evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value }, error: null
|
|
647
965
|
});
|
|
648
966
|
}
|
|
649
967
|
|
|
@@ -652,7 +970,38 @@ function appendActivity(content: string, line: string): string {
|
|
|
652
970
|
return `${body.replace(/\s+$/, '')}${body.trim() ? '\n' : ''}${line}`;
|
|
653
971
|
}
|
|
654
972
|
|
|
655
|
-
function
|
|
973
|
+
function deliveryIdentity(pullRequest: PullRequestSnapshot) {
|
|
974
|
+
return {
|
|
975
|
+
resource: pullRequestIdentity(pullRequest),
|
|
976
|
+
repository: pullRequest.repository,
|
|
977
|
+
url: pullRequest.url,
|
|
978
|
+
head: { ...pullRequest.head },
|
|
979
|
+
base: { ...pullRequest.base }
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function factTimestamp(timestamp: string): string {
|
|
984
|
+
return new Date(timestamp.replace(' ', 'T')).toISOString();
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function boundFactFor(
|
|
988
|
+
pullRequest: PullRequestSnapshot,
|
|
989
|
+
source: PrDeliveryBindingSource,
|
|
990
|
+
issue: number | ResourceIdentity | null,
|
|
991
|
+
verifiedAt: string
|
|
992
|
+
): PrDeliveryFact {
|
|
993
|
+
return buildBoundFact({
|
|
994
|
+
identity: deliveryIdentity(pullRequest),
|
|
995
|
+
source,
|
|
996
|
+
...(typeof issue === 'object' ? { issueIdentity: issue } : { issueNumber: issue }),
|
|
997
|
+
verifiedAt: factTimestamp(verifiedAt),
|
|
998
|
+
remoteState: pullRequest.state,
|
|
999
|
+
mergedAt: pullRequest.state === 'closed' ? pullRequest.mergedAt : null,
|
|
1000
|
+
mergeCommitSha: pullRequest.state === 'closed' ? pullRequest.mergeCommitSha : null
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function writeCreateStarted(base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true }, agent: string) {
|
|
656
1005
|
const starts = (base.content.match(/\*\*Create PR \[started\]\*\*/g) || []).length;
|
|
657
1006
|
const dones = (base.content.match(/\*\*Create PR\*\*/g) || []).length;
|
|
658
1007
|
if (starts > dones) return { status: 'no-op' as const };
|
|
@@ -666,19 +1015,40 @@ function writeCreateStarted(base: ReturnType<typeof resolvedContext> & { ok: tru
|
|
|
666
1015
|
}, { repoRoot: base.resolved.repoRoot, metadataProvider: () => metadata });
|
|
667
1016
|
}
|
|
668
1017
|
|
|
669
|
-
function bindIdentity(
|
|
670
|
-
|
|
671
|
-
|
|
1018
|
+
function bindIdentity(
|
|
1019
|
+
base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
|
|
1020
|
+
pullRequest: PullRequestSnapshot,
|
|
1021
|
+
agent: string,
|
|
1022
|
+
source: PrDeliveryBindingSource,
|
|
1023
|
+
dryRun = false,
|
|
1024
|
+
expectedHead?: string,
|
|
1025
|
+
expectedHeadSha?: string
|
|
1026
|
+
) {
|
|
1027
|
+
const identity = validateWriterIdentity(base, pullRequest, {
|
|
1028
|
+
expectedHead: source === 'explicit-bind' ? String(base.frontmatter.branch || '') : expectedHead,
|
|
1029
|
+
expectedHeadSha,
|
|
1030
|
+
errorCode: 'PR_BIND_IDENTITY_MISMATCH'
|
|
1031
|
+
});
|
|
1032
|
+
if (!identity.ok) return { status: 'failed' as const, error: identity.error };
|
|
1033
|
+
if (base.prIdentity && !resourceIdentityEquals(base.prIdentity, pullRequestIdentity(pullRequest))) return { status: 'failed' as const, error: {
|
|
1034
|
+
code: 'PR_BIND_CONFLICT', message: 'Task is already bound to a different pull request identity'
|
|
672
1035
|
} };
|
|
673
|
-
if (base.
|
|
1036
|
+
if (base.prIdentity && resourceIdentityEquals(base.prIdentity, pullRequestIdentity(pullRequest)) && base.fact?.state === 'bound') {
|
|
1037
|
+
if (JSON.stringify(base.fact.identity) !== JSON.stringify(deliveryIdentity(pullRequest))) return { status: 'failed' as const, error: {
|
|
1038
|
+
code: 'PR_BIND_IDENTITY_MISMATCH', message: `Task binding for PR #${pullRequest.number} does not match the fetched pull request identity`
|
|
1039
|
+
} };
|
|
1040
|
+
return { status: 'no-op' as const, error: null };
|
|
1041
|
+
}
|
|
674
1042
|
const current = fs.readFileSync(base.resolved.taskMdPath, 'utf8');
|
|
675
1043
|
const metadata = captureTaskWriteMetadata();
|
|
1044
|
+
const fact = boundFactFor(pullRequest, source, taskIssueIdentity(base.frontmatter), metadata.timestamp);
|
|
1045
|
+
const factMutation = factFrontmatterMutation(fact);
|
|
676
1046
|
return writeTask({
|
|
677
1047
|
taskRef: base.resolved.taskId,
|
|
678
1048
|
expectedState: 'active',
|
|
679
1049
|
dryRun,
|
|
680
1050
|
mutations: [
|
|
681
|
-
{ kind: 'frontmatter', set: {
|
|
1051
|
+
{ kind: 'frontmatter', set: { ...factMutation.set, assigned_to: agent } },
|
|
682
1052
|
{ kind: 'section', aliases: ['活动日志', 'Activity Log'], heading: '活动日志', body: appendActivity(
|
|
683
1053
|
current, `- ${metadata.timestamp} — **Create PR** by ${agent} — PR #${pullRequest.number} created → ${pullRequest.url}`
|
|
684
1054
|
) }
|
|
@@ -686,45 +1056,59 @@ function bindIdentity(base: ReturnType<typeof resolvedContext> & { ok: true }, p
|
|
|
686
1056
|
}, { repoRoot: base.resolved.repoRoot, metadataProvider: () => metadata });
|
|
687
1057
|
}
|
|
688
1058
|
|
|
689
|
-
function bindPlatformPullRequest(taskRef: string, options: BindOptions): PullRequestResult {
|
|
690
|
-
const base = resolvedContext(taskRef, options);
|
|
1059
|
+
async function bindPlatformPullRequest(taskRef: string, options: BindOptions): Promise<PullRequestResult> {
|
|
1060
|
+
const base = await resolvedContext(taskRef, options);
|
|
691
1061
|
if (!base.ok) return base.output;
|
|
692
|
-
if (!
|
|
693
|
-
error: { code: '
|
|
1062
|
+
if (!base.fact) return result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
1063
|
+
error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
|
|
1064
|
+
});
|
|
1065
|
+
let prIdentity: ResourceIdentity;
|
|
1066
|
+
try { prIdentity = providerResourceToken(base.provider, 'pull-request', String(options.pr)); }
|
|
1067
|
+
catch (error) { 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 } }); }
|
|
1068
|
+
const requestedNumber = resourceIdentityNumber(prIdentity);
|
|
1069
|
+
if (base.prIdentity && !resourceIdentityEquals(base.prIdentity, prIdentity)) return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1070
|
+
error: { code: 'PR_BIND_CONFLICT', message: 'Task is already bound to a different pull request identity', retryable: false }
|
|
694
1071
|
});
|
|
695
|
-
|
|
696
|
-
|
|
1072
|
+
const inspected = await inspectExternalPullRequest(base, prIdentity);
|
|
1073
|
+
if (inspected.status === 'failed' || inspected.status === 'blocked' || !inspected.pullRequest) return inspected;
|
|
1074
|
+
const initialIdentity = validateWriterIdentity(base, inspected.pullRequest, {
|
|
1075
|
+
expectedHead: String(base.frontmatter.branch || ''),
|
|
1076
|
+
errorCode: 'PR_BIND_IDENTITY_MISMATCH'
|
|
697
1077
|
});
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const written = bindIdentity(base, fetched.value, options.agent, options.dryRun);
|
|
701
|
-
if (written.status === 'failed') return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
702
|
-
pullRequest: fetched.value, error: { code: written.error.code, message: written.error.message, retryable: false }
|
|
1078
|
+
if (!initialIdentity.ok) return result('failed', base.resolved.taskId, base.issueNumber, requestedNumber, {
|
|
1079
|
+
pullRequest: inspected.pullRequest, error: initialIdentity.error
|
|
703
1080
|
});
|
|
704
|
-
|
|
1081
|
+
const written = bindIdentity(base, inspected.pullRequest, options.agent, 'explicit-bind', options.dryRun);
|
|
1082
|
+
if (written.status === 'failed') return result('failed', base.resolved.taskId, base.issueNumber, requestedNumber, {
|
|
1083
|
+
pullRequest: inspected.pullRequest, error: { code: written.error.code, message: written.error.message, retryable: false }
|
|
1084
|
+
});
|
|
1085
|
+
return result(options.dryRun ? 'planned' : written.status === 'no-op' ? 'no-op' : 'applied', base.resolved.taskId, base.issueNumber, options.dryRun ? base.prNumber : inspected.pullRequest.number, {
|
|
705
1086
|
changed: !options.dryRun && written.status === 'applied', platform: base.context.platform, capabilities: base.context.capabilities,
|
|
706
|
-
resource: { kind: 'pull-request', number:
|
|
1087
|
+
resource: { kind: 'pull-request', number: inspected.pullRequest.number, identity: pullRequestIdentity(inspected.pullRequest) }, pullRequest: inspected.pullRequest,
|
|
707
1088
|
operations: [{ name: 'task:bind-pr', status: options.dryRun ? 'planned' : written.status === 'no-op' ? 'no-op' : 'applied', reasonCode: null }], error: null
|
|
708
1089
|
});
|
|
709
1090
|
}
|
|
710
1091
|
|
|
711
1092
|
function externalEvidenceNote(
|
|
712
|
-
|
|
1093
|
+
issueIdentity: ResourceIdentity | null,
|
|
713
1094
|
source: 'unique' | 'explicit',
|
|
714
1095
|
pullRequest: PullRequestSnapshot
|
|
715
1096
|
): string {
|
|
716
1097
|
return [
|
|
717
1098
|
`authorization=${source}`,
|
|
718
|
-
externalIdentityEvidenceNote(
|
|
1099
|
+
externalIdentityEvidenceNote(issueIdentity, pullRequest)
|
|
719
1100
|
].join('; ');
|
|
720
1101
|
}
|
|
721
1102
|
|
|
722
1103
|
function externalIdentityEvidenceNote(
|
|
723
|
-
|
|
1104
|
+
issueIdentity: ResourceIdentity | null,
|
|
724
1105
|
pullRequest: PullRequestSnapshot
|
|
725
1106
|
): string {
|
|
1107
|
+
const issueLabel = issueIdentity
|
|
1108
|
+
? issueIdentity.kind === 'number' ? `#${issueIdentity.value}` : `${issueIdentity.kind}:${issueIdentity.value}`
|
|
1109
|
+
: 'unknown';
|
|
726
1110
|
return [
|
|
727
|
-
`issue
|
|
1111
|
+
`issue=${issueLabel}`,
|
|
728
1112
|
`pr=#${pullRequest.number}`,
|
|
729
1113
|
`url=${pullRequest.url}`,
|
|
730
1114
|
`base=${pullRequest.base.repository}:${pullRequest.base.ref}@${pullRequest.base.sha}`,
|
|
@@ -734,9 +1118,12 @@ function externalIdentityEvidenceNote(
|
|
|
734
1118
|
].join('; ');
|
|
735
1119
|
}
|
|
736
1120
|
|
|
737
|
-
function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOptions): ExternalPullRequestResult {
|
|
738
|
-
const base = resolvedContext(taskRef, options);
|
|
1121
|
+
async function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOptions): Promise<ExternalPullRequestResult> {
|
|
1122
|
+
const base = await resolvedContext(taskRef, options);
|
|
739
1123
|
if (!base.ok) return externalResult(base.output);
|
|
1124
|
+
if (!base.fact) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
1125
|
+
error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
|
|
1126
|
+
}));
|
|
740
1127
|
const inventory = inspectCompletionArtifacts(base.resolved.taskId, { repoRoot: base.resolved.repoRoot });
|
|
741
1128
|
if (inventory.status === 'failed') return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
742
1129
|
platform: base.context.platform,
|
|
@@ -756,28 +1143,41 @@ function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOpt
|
|
|
756
1143
|
error: null
|
|
757
1144
|
}), { mode: 'normal' });
|
|
758
1145
|
}
|
|
759
|
-
if (!base.
|
|
1146
|
+
if (!base.issueIdentity) return externalResult(result('failed', base.resolved.taskId, null, base.prNumber, {
|
|
760
1147
|
platform: base.context.platform,
|
|
761
1148
|
capabilities: base.context.capabilities,
|
|
762
1149
|
error: { code: 'EXTERNAL_DELIVERY_ISSUE_REQUIRED', message: 'External delivery requires a valid issue_number', retryable: false }
|
|
763
1150
|
}));
|
|
764
|
-
const inspected =
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1151
|
+
const inspected = base.provider.changeRequests?.listClosing
|
|
1152
|
+
? await base.provider.changeRequests.listClosing({
|
|
1153
|
+
context: providerOperationContext(base.loadedContext),
|
|
1154
|
+
issue: base.issueIdentity
|
|
1155
|
+
})
|
|
1156
|
+
: unsupportedProviderOperation(base.provider, 'changeRequests.listClosing');
|
|
770
1157
|
if (!inspected.ok || !inspected.value) {
|
|
771
|
-
const error =
|
|
1158
|
+
const error = 'error' in inspected && inspected.error
|
|
1159
|
+
? inspected.error
|
|
1160
|
+
: { code: 'PR_IDENTITY_INVALID', message: 'Closing PR inspection returned no candidates', retryable: false };
|
|
772
1161
|
return externalResult(result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
773
1162
|
platform: base.context.platform, capabilities: base.context.capabilities, error
|
|
774
1163
|
}));
|
|
775
1164
|
}
|
|
1165
|
+
const candidates = (inspected.value as ProviderChangeRequestSnapshot[]).map((item) => normalizeProviderPullRequest(item, base.context.platform.repository!, item.number || 0));
|
|
1166
|
+
let explicitPrIdentity: ResourceIdentity | null = null;
|
|
1167
|
+
if (options.pr !== undefined) {
|
|
1168
|
+
try { explicitPrIdentity = providerResourceToken(base.provider, 'pull-request', String(options.pr)); }
|
|
1169
|
+
catch (error) {
|
|
1170
|
+
return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1171
|
+
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
1172
|
+
error: { code: 'PLATFORM_IDENTITY_TOKEN_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
|
|
1173
|
+
}));
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
776
1176
|
const selected = selectExternalPullRequest(
|
|
777
|
-
|
|
1177
|
+
candidates,
|
|
778
1178
|
base.context.platform.repository!,
|
|
779
|
-
base.
|
|
780
|
-
|
|
1179
|
+
base.prIdentity,
|
|
1180
|
+
explicitPrIdentity
|
|
781
1181
|
);
|
|
782
1182
|
if (selected.status === 'normal') return externalResult(result('no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
783
1183
|
platform: base.context.platform,
|
|
@@ -791,32 +1191,66 @@ function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOpt
|
|
|
791
1191
|
error: { code: selected.code, message: selected.message, retryable: false }
|
|
792
1192
|
}), { candidates: selected.candidates, eligible: selected.eligible });
|
|
793
1193
|
|
|
794
|
-
const
|
|
795
|
-
|
|
1194
|
+
const initialIdentity = validateWriterIdentity(base, selected.selected, { errorCode: 'PR_EXTERNAL_IDENTITY_MISMATCH' });
|
|
1195
|
+
if (!initialIdentity.ok) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1196
|
+
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
1197
|
+
pullRequest: selected.selected, error: initialIdentity.error
|
|
1198
|
+
}), { candidates: selected.candidates, eligible: selected.eligible, selected: selected.selected });
|
|
1199
|
+
const rechecked = base.provider.changeRequests?.inspect
|
|
1200
|
+
? await base.provider.changeRequests.inspect({
|
|
1201
|
+
context: providerOperationContext(base.loadedContext),
|
|
1202
|
+
target: pullRequestIdentity(selected.selected)
|
|
1203
|
+
}).then((response) => response.ok
|
|
1204
|
+
? { ok: true as const, value: normalizeProviderPullRequest(response.value, base.context.platform.repository!, selected.selected.number, selected.selected) }
|
|
1205
|
+
: response)
|
|
1206
|
+
: unsupportedProviderOperation(base.provider, 'changeRequests.inspect');
|
|
1207
|
+
if (!rechecked.ok || !rechecked.value) return externalResult(result(rechecked.ok ? 'failed' : rechecked.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1208
|
+
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
1209
|
+
pullRequest: selected.selected,
|
|
1210
|
+
error: rechecked.ok ? { code: 'PR_EXTERNAL_RECHECK_FAILED', message: 'Selected pull request recheck returned no identity', retryable: false } : rechecked.error
|
|
1211
|
+
}), { candidates: selected.candidates, eligible: selected.eligible, selected: selected.selected });
|
|
1212
|
+
if (!sameDeliveryIdentity(selected.selected, rechecked.value)) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1213
|
+
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
1214
|
+
pullRequest: rechecked.value,
|
|
1215
|
+
error: { code: 'PR_EXTERNAL_IDENTITY_MISMATCH', message: 'Selected pull request identity changed before task binding', retryable: false }
|
|
1216
|
+
}), { candidates: selected.candidates, eligible: selected.eligible, selected: rechecked.value });
|
|
1217
|
+
const finalIdentity = validateWriterIdentity(base, rechecked.value, { errorCode: 'PR_EXTERNAL_IDENTITY_MISMATCH' });
|
|
1218
|
+
if (!finalIdentity.ok) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1219
|
+
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
1220
|
+
pullRequest: rechecked.value, error: finalIdentity.error
|
|
1221
|
+
}), { candidates: selected.candidates, eligible: selected.eligible, selected: rechecked.value });
|
|
1222
|
+
const finalEvidence = validateExternalMergedEvidence(selected.selected, rechecked.value);
|
|
1223
|
+
if (!finalEvidence.ok) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1224
|
+
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
1225
|
+
pullRequest: rechecked.value, error: finalEvidence.error
|
|
1226
|
+
}), { candidates: selected.candidates, eligible: selected.eligible, selected: rechecked.value });
|
|
1227
|
+
const selectedPullRequest = rechecked.value;
|
|
1228
|
+
const note = externalEvidenceNote(base.issueIdentity, selected.source, selectedPullRequest);
|
|
1229
|
+
const identityNote = externalIdentityEvidenceNote(base.issueIdentity, selectedPullRequest);
|
|
796
1230
|
const activityEntry = `**Bind External PR** by ${options.agent} — ${note}`;
|
|
797
1231
|
const auditLines = base.content.split(/\r?\n/).filter((line) => line.includes('**Bind External PR**'));
|
|
798
1232
|
const alreadyAudited = auditLines.some((line) => line.endsWith(identityNote));
|
|
799
1233
|
if (auditLines.length > 0 && !alreadyAudited) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
800
1234
|
platform: base.context.platform,
|
|
801
1235
|
capabilities: base.context.capabilities,
|
|
802
|
-
pullRequest:
|
|
1236
|
+
pullRequest: selectedPullRequest,
|
|
803
1237
|
error: { code: 'PR_BIND_CONFLICT', message: 'Existing external PR audit evidence conflicts with the selected identity', retryable: false }
|
|
804
|
-
}), { candidates: selected.candidates, eligible: selected.eligible, selected:
|
|
805
|
-
if (alreadyAudited && base.
|
|
1238
|
+
}), { candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
|
|
1239
|
+
if (alreadyAudited && base.prIdentity && !resourceIdentityEquals(base.prIdentity, pullRequestIdentity(selectedPullRequest))) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
806
1240
|
platform: base.context.platform,
|
|
807
1241
|
capabilities: base.context.capabilities,
|
|
808
|
-
pullRequest:
|
|
1242
|
+
pullRequest: selectedPullRequest,
|
|
809
1243
|
error: { code: 'PR_BIND_CONFLICT', message: 'External PR audit evidence does not match the task binding', retryable: false }
|
|
810
|
-
}), { candidates: selected.candidates, eligible: selected.eligible, selected:
|
|
811
|
-
if (base.
|
|
1244
|
+
}), { candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
|
|
1245
|
+
if (base.prIdentity && resourceIdentityEquals(base.prIdentity, pullRequestIdentity(selectedPullRequest)) && alreadyAudited) {
|
|
812
1246
|
return externalResult(result('no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
813
1247
|
platform: base.context.platform,
|
|
814
1248
|
capabilities: base.context.capabilities,
|
|
815
|
-
resource: { kind: 'pull-request', number:
|
|
816
|
-
pullRequest:
|
|
1249
|
+
resource: { kind: 'pull-request', number: selectedPullRequest.number },
|
|
1250
|
+
pullRequest: selectedPullRequest,
|
|
817
1251
|
operations: [{ name: 'task:bind-external-pr', status: 'no-op', reasonCode: 'EVIDENCE_ALREADY_RECORDED' }],
|
|
818
1252
|
error: null
|
|
819
|
-
}), { mode: 'external', authorization: selected.source, candidates: selected.candidates, eligible: selected.eligible, selected:
|
|
1253
|
+
}), { mode: 'external', authorization: selected.source, candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
|
|
820
1254
|
}
|
|
821
1255
|
const metadata = captureTaskWriteMetadata();
|
|
822
1256
|
const write = writeTask({
|
|
@@ -824,7 +1258,7 @@ function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOpt
|
|
|
824
1258
|
expectedState: 'active',
|
|
825
1259
|
dryRun: options.dryRun,
|
|
826
1260
|
mutations: [
|
|
827
|
-
{ kind: 'frontmatter', set: {
|
|
1261
|
+
{ kind: 'frontmatter', set: { ...factFrontmatterMutation(boundFactFor(selectedPullRequest, selected.source === 'unique' ? 'external-unique' : 'external-explicit', base.issueNumber, metadata.timestamp)).set, assigned_to: options.agent } },
|
|
828
1262
|
{ kind: 'section', aliases: ['活动日志', 'Activity Log'], heading: '活动日志', body: appendActivity(
|
|
829
1263
|
base.content, `- ${metadata.timestamp} — ${activityEntry}`
|
|
830
1264
|
) }
|
|
@@ -833,173 +1267,45 @@ function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOpt
|
|
|
833
1267
|
if (write.status === 'failed') return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
834
1268
|
platform: base.context.platform,
|
|
835
1269
|
capabilities: base.context.capabilities,
|
|
836
|
-
pullRequest:
|
|
1270
|
+
pullRequest: selectedPullRequest,
|
|
837
1271
|
error: { code: write.error.code, message: write.error.message, retryable: false }
|
|
838
|
-
}), { candidates: selected.candidates, eligible: selected.eligible, selected:
|
|
1272
|
+
}), { candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
|
|
839
1273
|
const operationStatus = options.dryRun ? 'planned' : write.status === 'applied' ? 'applied' : 'no-op';
|
|
840
|
-
return externalResult(result(operationStatus, base.resolved.taskId, base.issueNumber, options.dryRun ? base.prNumber :
|
|
1274
|
+
return externalResult(result(operationStatus, base.resolved.taskId, base.issueNumber, options.dryRun ? base.prNumber : selectedPullRequest.number, {
|
|
841
1275
|
changed: !options.dryRun && write.changed,
|
|
842
1276
|
platform: base.context.platform,
|
|
843
1277
|
capabilities: base.context.capabilities,
|
|
844
|
-
resource: { kind: 'pull-request', number:
|
|
845
|
-
pullRequest:
|
|
1278
|
+
resource: { kind: 'pull-request', number: selectedPullRequest.number },
|
|
1279
|
+
pullRequest: selectedPullRequest,
|
|
846
1280
|
operations: [{ name: 'task:bind-external-pr', status: operationStatus, reasonCode: null }],
|
|
847
1281
|
error: null
|
|
848
|
-
}), { mode: 'external', authorization: selected.source, candidates: selected.candidates, eligible: selected.eligible, selected:
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
function createPlatformPullRequestUnlocked(taskRef: string, options: CreateOptions): PullRequestResult {
|
|
852
|
-
const base = resolvedContext(taskRef, options);
|
|
853
|
-
if (!base.ok) return base.output;
|
|
854
|
-
if (!options.base || !options.head || !options.title.trim() || !options.body.trim()) return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
855
|
-
error: { code: 'PR_PAYLOAD_INVALID', message: 'base, head, title and body are required', retryable: false }
|
|
856
|
-
});
|
|
857
|
-
if (base.prNumber) {
|
|
858
|
-
const headCheck = verifyCreateHead(base, options.head);
|
|
859
|
-
if (!headCheck.ok) return result(headCheck.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
860
|
-
platform: base.context.platform,
|
|
861
|
-
capabilities: base.context.capabilities,
|
|
862
|
-
error: headCheck.error
|
|
863
|
-
});
|
|
864
|
-
const inspected = inspectPlatformPullRequest(taskRef, options);
|
|
865
|
-
const identity = validateBoundPullRequest(base, inspected.pullRequest, options.head, options.base, headCheck.value);
|
|
866
|
-
if (!identity.ok) return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
867
|
-
platform: base.context.platform,
|
|
868
|
-
capabilities: base.context.capabilities,
|
|
869
|
-
pullRequest: inspected.pullRequest,
|
|
870
|
-
error: identity.error
|
|
871
|
-
});
|
|
872
|
-
return { ...inspected, result: 'no_op' };
|
|
873
|
-
}
|
|
874
|
-
const headCheck = verifyCreateHead(base, options.head);
|
|
875
|
-
if (!headCheck.ok) return result(headCheck.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, null, {
|
|
876
|
-
platform: base.context.platform,
|
|
877
|
-
capabilities: base.context.capabilities,
|
|
878
|
-
error: headCheck.error
|
|
879
|
-
});
|
|
880
|
-
const expectedHeadSha = headCheck.value;
|
|
881
|
-
const located = locatePullRequest(base, options.head, options.base, expectedHeadSha);
|
|
882
|
-
if (!located.ok && located.error.code === 'PR_IDENTITY_AMBIGUOUS') return result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
883
|
-
platform: base.context.platform, capabilities: base.context.capabilities, error: located.error
|
|
884
|
-
});
|
|
885
|
-
if (!located.ok && located.error.code === 'PR_HEAD_SHA_MISMATCH') return result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
886
|
-
platform: base.context.platform, capabilities: base.context.capabilities, error: located.error
|
|
887
|
-
});
|
|
888
|
-
if (options.dryRun) return result('planned', base.resolved.taskId, base.issueNumber, null, {
|
|
889
|
-
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
890
|
-
operations: [{ name: located.ok ? 'pr:reuse' : 'pr:create', status: 'planned', reasonCode: null }],
|
|
891
|
-
result: located.ok ? 'pr_reused' : 'pr_created', error: null
|
|
892
|
-
});
|
|
893
|
-
const started = writeCreateStarted(base, options.agent);
|
|
894
|
-
if (started.status === 'failed') return result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
895
|
-
error: { code: started.error.code, message: started.error.message, retryable: false }
|
|
896
|
-
});
|
|
897
|
-
let pullRequest = located.ok ? located.value : null;
|
|
898
|
-
let created = false;
|
|
899
|
-
if (!pullRequest) {
|
|
900
|
-
const repository = base.context.platform.repository!;
|
|
901
|
-
const response = base.client.json<RemotePullRequest>(['api', `repos/${repository}/pulls`, '-X', 'POST', '--input', '-'], {
|
|
902
|
-
cwd: base.resolved.repoRoot,
|
|
903
|
-
method: 'POST',
|
|
904
|
-
input: JSON.stringify({ title: options.title, body: options.body, head: options.head, base: options.base, draft: Boolean(options.draft) })
|
|
905
|
-
});
|
|
906
|
-
if (response.ok) pullRequest = normalizePullRequest(response.value, repository);
|
|
907
|
-
else if (response.error.retryable) {
|
|
908
|
-
const recovered = locatePullRequest(base, options.head, options.base, expectedHeadSha);
|
|
909
|
-
if (recovered.ok) pullRequest = recovered.value;
|
|
910
|
-
else return result('blocked', base.resolved.taskId, base.issueNumber, null, {
|
|
911
|
-
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
912
|
-
operations: [{ name: 'pr:create', status: 'failed', reasonCode: 'PR_CREATE_OUTCOME_UNKNOWN' }],
|
|
913
|
-
error: { code: 'PR_CREATE_OUTCOME_UNKNOWN', message: response.error.message, retryable: true }
|
|
914
|
-
});
|
|
915
|
-
} else return result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
916
|
-
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
917
|
-
operations: [{ name: 'pr:create', status: 'failed', reasonCode: response.error.code }], error: response.error
|
|
918
|
-
});
|
|
919
|
-
if (!pullRequest) return result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
920
|
-
error: { code: 'PR_CREATE_RESPONSE_INVALID', message: 'PR create response lacks validated identity', retryable: false }
|
|
921
|
-
});
|
|
922
|
-
if (pullRequest.head.sha !== expectedHeadSha) return result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
|
|
923
|
-
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
924
|
-
resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest,
|
|
925
|
-
error: { code: 'PR_HEAD_SHA_MISMATCH', message: 'Created pull request head does not match the expected local HEAD', retryable: false }
|
|
926
|
-
});
|
|
927
|
-
created = true;
|
|
928
|
-
}
|
|
929
|
-
const bindHeadCheck = verifyCreateHead(base, options.head);
|
|
930
|
-
if (!bindHeadCheck.ok || bindHeadCheck.value !== expectedHeadSha) {
|
|
931
|
-
const error = bindHeadCheck.ok
|
|
932
|
-
? { code: 'PR_REMOTE_HEAD_MISMATCH', message: 'Remote head changed before task binding', retryable: false }
|
|
933
|
-
: bindHeadCheck.error;
|
|
934
|
-
return result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
|
|
935
|
-
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
936
|
-
resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest, error
|
|
937
|
-
});
|
|
938
|
-
}
|
|
939
|
-
const beforeBind = inspectGitHubPullRequest(base.client, base.context.platform.repository!, pullRequest.number, base.resolved.repoRoot);
|
|
940
|
-
if (!beforeBind.ok || !beforeBind.value) {
|
|
941
|
-
const error = beforeBind.ok
|
|
942
|
-
? { code: 'PR_BIND_RECHECK_FAILED', message: 'Pull request recheck returned no identity', retryable: false }
|
|
943
|
-
: beforeBind.error;
|
|
944
|
-
return result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
|
|
945
|
-
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
946
|
-
resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest, error
|
|
947
|
-
});
|
|
948
|
-
}
|
|
949
|
-
if (
|
|
950
|
-
beforeBind.value.head.sha !== expectedHeadSha
|
|
951
|
-
|| beforeBind.value.head.ref !== expectedHead(base.context.platform.repository!, options.head).ref
|
|
952
|
-
|| beforeBind.value.base.ref !== options.base
|
|
953
|
-
|| beforeBind.value.base.repository !== base.context.platform.repository
|
|
954
|
-
) return result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
|
|
955
|
-
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
956
|
-
resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest: beforeBind.value,
|
|
957
|
-
error: { code: 'PR_BIND_IDENTITY_MISMATCH', message: 'Pull request identity changed before task binding', retryable: false }
|
|
958
|
-
});
|
|
959
|
-
pullRequest = beforeBind.value;
|
|
960
|
-
const refreshed = resolvedContext(taskRef, options);
|
|
961
|
-
if (!refreshed.ok) return result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
962
|
-
pullRequest, error: { code: 'PR_CREATED_BIND_FAILED', message: pullRequest.url, retryable: false }
|
|
963
|
-
});
|
|
964
|
-
const bound = bindIdentity(refreshed, pullRequest, options.agent);
|
|
965
|
-
if (bound.status === 'failed') return result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
966
|
-
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
967
|
-
resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest,
|
|
968
|
-
operations: [{ name: created ? 'pr:create' : 'pr:reuse', status: 'applied', reasonCode: null }, { name: 'task:bind-pr', status: 'failed', reasonCode: bound.error.code }],
|
|
969
|
-
error: { code: 'PR_CREATED_BIND_FAILED', message: `${pullRequest.url}: ${bound.error.message}`, retryable: false }
|
|
970
|
-
});
|
|
971
|
-
return result('applied', base.resolved.taskId, base.issueNumber, pullRequest.number, {
|
|
972
|
-
changed: true, platform: base.context.platform, capabilities: base.context.capabilities,
|
|
973
|
-
resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest,
|
|
974
|
-
operations: [{ name: created ? 'pr:create' : 'pr:reuse', status: created ? 'applied' : 'no-op', reasonCode: null }, { name: 'task:bind-pr', status: 'applied', reasonCode: null }],
|
|
975
|
-
result: created ? 'pr_created' : 'pr_reused', error: null
|
|
976
|
-
});
|
|
1282
|
+
}), { mode: 'external', authorization: selected.source, candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
|
|
977
1283
|
}
|
|
978
1284
|
|
|
979
|
-
function createPlatformPullRequest(taskRef: string, options: CreateOptions): PullRequestResult {
|
|
980
|
-
const base = resolvedContext(taskRef, options);
|
|
981
|
-
if (!base.ok) return base.output;
|
|
1285
|
+
async function createPlatformPullRequest(taskRef: string, options: CreateOptions): Promise<PullRequestResult> {
|
|
1286
|
+
const base = await resolvedContext(taskRef, options);
|
|
1287
|
+
if (!base.ok) return withCreation(base.output, PRECONDITION_NOT_CREATED);
|
|
982
1288
|
if (!options.base || !options.head || !options.title.trim() || !options.body.trim()) {
|
|
983
|
-
return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1289
|
+
return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
984
1290
|
error: { code: 'PR_PAYLOAD_INVALID', message: 'base, head, title and body are required', retryable: false }
|
|
985
|
-
});
|
|
1291
|
+
}), PRECONDITION_NOT_CREATED);
|
|
986
1292
|
}
|
|
987
1293
|
try {
|
|
988
1294
|
return withTaskExecutionLock(
|
|
989
1295
|
base.resolved.repoRoot,
|
|
990
1296
|
base.resolved.taskId,
|
|
991
1297
|
'platform-pr.create',
|
|
992
|
-
() =>
|
|
1298
|
+
() => createExternalPullRequest(taskRef, options, base)
|
|
993
1299
|
);
|
|
994
1300
|
} catch (error) {
|
|
995
1301
|
if (error instanceof TaskExecutionLockError) {
|
|
996
|
-
return result('blocked', base.resolved.taskId, base.issueNumber, null, {
|
|
1302
|
+
return withCreation(result('blocked', base.resolved.taskId, base.issueNumber, null, {
|
|
997
1303
|
platform: base.context.platform,
|
|
998
1304
|
capabilities: base.context.capabilities,
|
|
999
1305
|
error: { code: error.code, message: error.message, retryable: true }
|
|
1000
|
-
});
|
|
1306
|
+
}), PRECONDITION_NOT_CREATED);
|
|
1001
1307
|
}
|
|
1002
|
-
return result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
1308
|
+
return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
1003
1309
|
platform: base.context.platform,
|
|
1004
1310
|
capabilities: base.context.capabilities,
|
|
1005
1311
|
error: {
|
|
@@ -1007,14 +1313,146 @@ function createPlatformPullRequest(taskRef: string, options: CreateOptions): Pul
|
|
|
1007
1313
|
message: error instanceof Error ? error.message : String(error),
|
|
1008
1314
|
retryable: false
|
|
1009
1315
|
}
|
|
1316
|
+
}), PRECONDITION_NOT_CREATED);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
async function createExternalPullRequest(
|
|
1321
|
+
taskRef: string,
|
|
1322
|
+
options: CreateOptions,
|
|
1323
|
+
base: Extract<Awaited<ReturnType<typeof resolvedContext>>, { ok: true }>
|
|
1324
|
+
): Promise<PullRequestResult> {
|
|
1325
|
+
if (!base.fact) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
1326
|
+
error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
|
|
1327
|
+
}), PRECONDITION_NOT_CREATED);
|
|
1328
|
+
const target = taskDeliveryTarget(base);
|
|
1329
|
+
if (!target.ok) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1330
|
+
error: target.error
|
|
1331
|
+
}), PRECONDITION_NOT_CREATED);
|
|
1332
|
+
if (options.base !== target.value.baseRef) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1333
|
+
platform: base.context.platform,
|
|
1334
|
+
capabilities: base.context.capabilities,
|
|
1335
|
+
error: { code: 'PR_DELIVERY_TARGET_MISMATCH', message: `Pull request base '${options.base}' does not match delivery target '${target.value.baseRef}'`, retryable: false }
|
|
1336
|
+
}), PRECONDITION_NOT_CREATED);
|
|
1337
|
+
let verifiedHeadSha: string | undefined;
|
|
1338
|
+
if (base.provider.changeRequests?.verifyHead) {
|
|
1339
|
+
const verified = await base.provider.changeRequests.verifyHead({
|
|
1340
|
+
context: providerOperationContext(base.loadedContext),
|
|
1341
|
+
head: options.head
|
|
1010
1342
|
});
|
|
1343
|
+
if (!verified.ok) return withCreation(providerPullRequestError(base, verified.error, base.prNumber), PRECONDITION_NOT_CREATED);
|
|
1344
|
+
verifiedHeadSha = verified.value.sha;
|
|
1345
|
+
}
|
|
1346
|
+
if (base.prIdentity) {
|
|
1347
|
+
const inspected = await inspectExternalPullRequest(base, base.prIdentity!);
|
|
1348
|
+
return inspected.status === 'failed' || inspected.status === 'blocked'
|
|
1349
|
+
? withCreation(inspected, PRECONDITION_NOT_CREATED)
|
|
1350
|
+
: withCreation({ ...inspected, result: 'no_op' }, { kind: 'no-op', createdByCurrentOperation: false });
|
|
1011
1351
|
}
|
|
1352
|
+
if (options.dryRun) return withCreation(result('planned', base.resolved.taskId, base.issueNumber, null, {
|
|
1353
|
+
platform: base.context.platform,
|
|
1354
|
+
capabilities: base.context.capabilities,
|
|
1355
|
+
operations: [{ name: 'pr:create', status: 'planned', reasonCode: null }],
|
|
1356
|
+
result: 'pr_created',
|
|
1357
|
+
error: null
|
|
1358
|
+
}), { kind: 'planned', action: 'create', createdByCurrentOperation: false });
|
|
1359
|
+
|
|
1360
|
+
const started = writeCreateStarted(base, options.agent);
|
|
1361
|
+
if (started.status === 'failed') return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
1362
|
+
error: { code: started.error.code, message: started.error.message, retryable: false }
|
|
1363
|
+
}), PRECONDITION_NOT_CREATED);
|
|
1364
|
+
const created = base.provider.changeRequests?.create
|
|
1365
|
+
? await base.provider.changeRequests.create({
|
|
1366
|
+
context: providerOperationContext(base.loadedContext),
|
|
1367
|
+
base: options.base,
|
|
1368
|
+
head: options.head,
|
|
1369
|
+
title: options.title,
|
|
1370
|
+
body: options.body,
|
|
1371
|
+
draft: Boolean(options.draft),
|
|
1372
|
+
mutation: { idempotencyKey: `pull-request:create:${base.resolved.taskId}` }
|
|
1373
|
+
})
|
|
1374
|
+
: unsupportedProviderOperation(base.provider, 'changeRequests.create');
|
|
1375
|
+
if (!created.ok) return withCreation(providerPullRequestError(base, created.error, null), { kind: 'unknown', errorCode: 'PR_CREATE_OUTCOME_UNKNOWN' });
|
|
1376
|
+
const remoteId = String(created.value.remoteId || '');
|
|
1377
|
+
let createdIdentity: ResourceIdentity;
|
|
1378
|
+
try { createdIdentity = providerResourceIdentity(base.provider, 'pull-request', remoteId); }
|
|
1379
|
+
catch (error) {
|
|
1380
|
+
return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
1381
|
+
platform: base.context.platform, capabilities: base.context.capabilities,
|
|
1382
|
+
error: { code: 'PR_CREATE_RESPONSE_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
|
|
1383
|
+
}), { kind: 'created', createdByCurrentOperation: true });
|
|
1384
|
+
}
|
|
1385
|
+
const number = resourceIdentityNumber(createdIdentity);
|
|
1386
|
+
const fallbackNumber = number ?? 0;
|
|
1387
|
+
const fallback = normalizeProviderPullRequest({
|
|
1388
|
+
id: remoteId,
|
|
1389
|
+
identity: createdIdentity,
|
|
1390
|
+
number: number || undefined,
|
|
1391
|
+
state: 'open',
|
|
1392
|
+
title: options.title,
|
|
1393
|
+
body: options.body,
|
|
1394
|
+
headSha: '',
|
|
1395
|
+
baseSha: '',
|
|
1396
|
+
head: { repository: base.context.platform.repository!, ref: options.head, sha: '' },
|
|
1397
|
+
base: { repository: base.context.platform.repository!, ref: options.base, sha: '' },
|
|
1398
|
+
displayUrl: `${base.provider.type}:${remoteId}`
|
|
1399
|
+
}, base.context.platform.repository!, fallbackNumber);
|
|
1400
|
+
const inspected = base.provider.changeRequests?.inspect
|
|
1401
|
+
? await base.provider.changeRequests.inspect({
|
|
1402
|
+
context: providerOperationContext(base.loadedContext),
|
|
1403
|
+
target: createdIdentity
|
|
1404
|
+
})
|
|
1405
|
+
: unsupportedProviderOperation(base.provider, 'changeRequests.inspect');
|
|
1406
|
+
if (!inspected.ok) return withCreation(providerPullRequestError(base, inspected.error, number), { kind: 'created', createdByCurrentOperation: true });
|
|
1407
|
+
const pullRequest = normalizeProviderPullRequest(
|
|
1408
|
+
inspected.value,
|
|
1409
|
+
base.context.platform.repository!,
|
|
1410
|
+
inspected.value.number ?? fallback.number,
|
|
1411
|
+
fallback
|
|
1412
|
+
);
|
|
1413
|
+
if (!pullRequest.nodeId || !isUsableResourceIdentity(pullRequestIdentity(pullRequest))) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
1414
|
+
platform: base.context.platform,
|
|
1415
|
+
capabilities: base.context.capabilities,
|
|
1416
|
+
error: { code: 'PR_CREATE_RESPONSE_INVALID', message: 'Provider create response lacks validated identity', retryable: false }
|
|
1417
|
+
}), { kind: 'created', createdByCurrentOperation: true });
|
|
1418
|
+
const identity = validateWriterIdentity(base, pullRequest, {
|
|
1419
|
+
expectedHead: options.head,
|
|
1420
|
+
expectedHeadSha: verifiedHeadSha,
|
|
1421
|
+
errorCode: 'PR_BIND_IDENTITY_MISMATCH'
|
|
1422
|
+
});
|
|
1423
|
+
if (!identity.ok) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
|
|
1424
|
+
platform: base.context.platform,
|
|
1425
|
+
capabilities: base.context.capabilities,
|
|
1426
|
+
resource: { kind: 'pull-request', number: pullRequest.number },
|
|
1427
|
+
pullRequest,
|
|
1428
|
+
error: identity.error
|
|
1429
|
+
}), { kind: 'created', createdByCurrentOperation: true });
|
|
1430
|
+
const bound = bindIdentity(base, pullRequest, options.agent, 'created', false, options.head);
|
|
1431
|
+
if (bound.status === 'failed') return withCreation(result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
|
|
1432
|
+
platform: base.context.platform,
|
|
1433
|
+
capabilities: base.context.capabilities,
|
|
1434
|
+
resource: { kind: 'pull-request', number: pullRequest.number },
|
|
1435
|
+
pullRequest,
|
|
1436
|
+
error: { code: 'PR_CREATED_BIND_FAILED', message: `${pullRequest.url}: ${bound.error.message}`, retryable: false }
|
|
1437
|
+
}), { kind: 'created', createdByCurrentOperation: true });
|
|
1438
|
+
return withCreation(result('applied', base.resolved.taskId, base.issueNumber, pullRequest.number, {
|
|
1439
|
+
changed: true,
|
|
1440
|
+
platform: base.context.platform,
|
|
1441
|
+
capabilities: base.context.capabilities,
|
|
1442
|
+
resource: { kind: 'pull-request', number: pullRequest.number },
|
|
1443
|
+
pullRequest,
|
|
1444
|
+
operations: [{ name: 'pr:create', status: 'applied', reasonCode: null }, { name: 'task:bind-pr', status: 'applied', reasonCode: null }],
|
|
1445
|
+
result: 'pr_created',
|
|
1446
|
+
error: null
|
|
1447
|
+
}), { kind: 'created', createdByCurrentOperation: true });
|
|
1012
1448
|
}
|
|
1013
1449
|
|
|
1014
|
-
function syncPlatformPullRequest(taskRef: string, options: SyncOptions): PullRequestResult {
|
|
1450
|
+
async function syncPlatformPullRequest(taskRef: string, options: SyncOptions): Promise<PullRequestResult> {
|
|
1015
1451
|
const warningResult = warningResultForPrimary(options.primaryResult);
|
|
1016
1452
|
const softenFailure = (output: PullRequestResult): PullRequestResult => {
|
|
1453
|
+
const authorityError = output.error?.code.startsWith('IN_LABEL_SYNC') || output.error?.code.startsWith('PR_IDENTITY');
|
|
1017
1454
|
const warning = output.error && output.task.prNumber !== null
|
|
1455
|
+
&& !authorityError
|
|
1018
1456
|
&& !['PR_NOT_LINKED', 'PR_BIND_CONFLICT'].includes(output.error.code)
|
|
1019
1457
|
? {
|
|
1020
1458
|
code: output.error.code,
|
|
@@ -1029,63 +1467,245 @@ function syncPlatformPullRequest(taskRef: string, options: SyncOptions): PullReq
|
|
|
1029
1467
|
? { ...output, status: 'applied', changed: false, error: null, result: warningResult, warnings: mergeOperationWarnings([warning]) }
|
|
1030
1468
|
: output;
|
|
1031
1469
|
};
|
|
1032
|
-
const base = resolvedContext(taskRef, options);
|
|
1470
|
+
const base = await resolvedContext(taskRef, options);
|
|
1033
1471
|
if (!base.ok) return softenFailure(base.output);
|
|
1034
|
-
if (!base.
|
|
1035
|
-
error: { code: '
|
|
1036
|
-
}));
|
|
1037
|
-
const fetched = inspectGitHubPullRequest(base.client, base.context.platform.repository!, base.prNumber, base.resolved.repoRoot);
|
|
1038
|
-
if (!fetched.ok) return softenFailure(result(fetched.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, { error: fetched.error }));
|
|
1039
|
-
if (!options.metadata && !options.closingIssue) return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1040
|
-
error: { code: 'PR_PAYLOAD_INVALID', message: 'sync requires a desired-state option', retryable: false }
|
|
1041
|
-
}));
|
|
1042
|
-
if (!base.issueNumber) return softenFailure(result('degraded', base.resolved.taskId, null, base.prNumber, {
|
|
1043
|
-
pullRequest: fetched.value, error: { code: 'ISSUE_NOT_LINKED', message: 'Task has no linked Issue to copy metadata from', retryable: false }
|
|
1044
|
-
}));
|
|
1045
|
-
const issue = inspectPlatformIssue(taskRef, { cwd: base.resolved.repoRoot, client: base.client });
|
|
1046
|
-
if (!issue.issue) return softenFailure(result(issue.status, base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1047
|
-
platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: fetched.value, error: issue.error
|
|
1472
|
+
if (!base.fact) return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
1473
|
+
error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
|
|
1048
1474
|
}));
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
issue: issue.issue,
|
|
1052
|
-
taskType: base.frontmatter.type || 'task',
|
|
1053
|
-
issueNumber: base.issueNumber,
|
|
1054
|
-
capabilities: base.context.capabilities
|
|
1055
|
-
}).operations.filter((operation) => options.metadata || operation.name === 'closing-issue')
|
|
1056
|
-
.filter((operation) => options.closingIssue || operation.name !== 'closing-issue');
|
|
1057
|
-
const operations: PlatformOperation[] = planned.map(({ name, status, reasonCode }) => ({ name, status, reasonCode }));
|
|
1058
|
-
if (options.dryRun) return softenFailure(result(planned.some((operation) => operation.status === 'planned') ? 'planned' : 'no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1059
|
-
platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: fetched.value, operations, error: null
|
|
1475
|
+
if (!base.prIdentity) return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, null, {
|
|
1476
|
+
error: { code: 'PR_NOT_LINKED', message: 'Task has no verified bound pull request', retryable: false }
|
|
1060
1477
|
}));
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
if (
|
|
1064
|
-
if (
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1478
|
+
{
|
|
1479
|
+
const inspected = await inspectExternalPullRequest(base, base.prIdentity!);
|
|
1480
|
+
if (inspected.status === 'failed' || inspected.status === 'blocked' || !inspected.pullRequest) return softenFailure(inspected);
|
|
1481
|
+
if (!options.metadata && !options.closingIssue) return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1482
|
+
error: { code: 'PR_PAYLOAD_INVALID', message: 'sync requires a desired-state option', retryable: false }
|
|
1483
|
+
}));
|
|
1484
|
+
const issue = await inspectPlatformIssue(taskRef, { cwd: base.resolved.repoRoot, client: base.client });
|
|
1485
|
+
if (!issue.issue) return softenFailure(result(issue.status, base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1486
|
+
platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: inspected.pullRequest, error: issue.error
|
|
1487
|
+
}));
|
|
1488
|
+
let inLabels = inspected.pullRequest.labels.filter((label) => label.startsWith('in:')).sort();
|
|
1489
|
+
let issueInLabelOperation: PlatformOperation | null = null;
|
|
1490
|
+
if (options.metadata && base.provider.issues?.listLabels) {
|
|
1491
|
+
const labels = await base.provider.issues.listLabels({ context: providerOperationContext(base.loadedContext) });
|
|
1492
|
+
if (!labels.ok) return softenFailure(providerPullRequestError(base, labels.error, base.prNumber));
|
|
1493
|
+
const target = taskInLabelTarget(base.resolved.repoRoot, base.frontmatter, inspected.pullRequest.labels, new Set(labels.value));
|
|
1494
|
+
if (!target.ok) return softenFailure(providerPullRequestError(base, target.error, base.prNumber));
|
|
1495
|
+
inLabels = target.value;
|
|
1496
|
+
const currentIssueLabels = issue.issue.labels.filter((label) => label.startsWith('in:')).sort();
|
|
1497
|
+
issueInLabelOperation = base.context.capabilities.triage
|
|
1498
|
+
? currentIssueLabels.join('\0') === inLabels.join('\0')
|
|
1499
|
+
? { name: 'labels:in:issue', status: 'no-op', reasonCode: null }
|
|
1500
|
+
: { name: 'labels:in:issue', status: 'planned', reasonCode: null }
|
|
1501
|
+
: { name: 'labels:in:issue', status: 'skipped', reasonCode: 'TRIAGE_REQUIRED' };
|
|
1072
1502
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1503
|
+
const planned = planPullRequestMetadata({
|
|
1504
|
+
pullRequest: inspected.pullRequest,
|
|
1505
|
+
issue: issue.issue,
|
|
1506
|
+
taskType: String(base.frontmatter.type || 'task'),
|
|
1507
|
+
issueNumber: base.issueNumber,
|
|
1508
|
+
capabilities: base.context.capabilities,
|
|
1509
|
+
inLabels
|
|
1510
|
+
}).operations.filter((operation) => options.metadata || operation.name === 'closing-issue')
|
|
1511
|
+
.filter((operation) => options.closingIssue || operation.name !== 'closing-issue');
|
|
1512
|
+
const operations: PlatformOperation[] = [
|
|
1513
|
+
...(issueInLabelOperation ? [issueInLabelOperation] : []),
|
|
1514
|
+
...planned.map(({ name, status, reasonCode }) => ({ name, status, reasonCode }))
|
|
1515
|
+
];
|
|
1516
|
+
const milestoneOperation = planned.find((operation) => operation.name === 'milestone' && operation.status === 'planned');
|
|
1517
|
+
if (milestoneOperation && base.provider.issues?.listMilestones) {
|
|
1518
|
+
const milestones = await base.provider.issues.listMilestones({ context: providerOperationContext(base.loadedContext) });
|
|
1519
|
+
if (!milestones.ok) return providerPullRequestError(base, milestones.error, base.prNumber);
|
|
1520
|
+
}
|
|
1521
|
+
if (options.dryRun) return softenFailure(result(planned.some((operation) => operation.status === 'planned') ? 'planned' : 'no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1522
|
+
platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: inspected.pullRequest, operations, error: null
|
|
1523
|
+
}));
|
|
1524
|
+
let issueChanged = false;
|
|
1525
|
+
if (issueInLabelOperation?.status === 'planned') {
|
|
1526
|
+
const desiredIssueLabels = [...new Set([
|
|
1527
|
+
...issue.issue.labels.filter((label) => !label.startsWith('in:')),
|
|
1528
|
+
...inLabels
|
|
1529
|
+
])].sort();
|
|
1530
|
+
const updatedIssue = base.provider.issues?.update
|
|
1531
|
+
? await base.provider.issues.update({
|
|
1532
|
+
context: providerOperationContext(base.loadedContext),
|
|
1533
|
+
target: base.issueIdentity!,
|
|
1534
|
+
currentLabels: issue.issue.labels,
|
|
1535
|
+
patch: { labels: desiredIssueLabels },
|
|
1536
|
+
mutation: { idempotencyKey: `issue:update:${base.resolved.taskId}:in-labels` }
|
|
1537
|
+
})
|
|
1538
|
+
: unsupportedProviderOperation(base.provider, 'issues.update');
|
|
1539
|
+
if (!updatedIssue.ok) return softenFailure(providerPullRequestError(base, updatedIssue.error, base.prNumber));
|
|
1540
|
+
issueChanged = updatedIssue.value.changed;
|
|
1541
|
+
const rereadIssue = base.provider.issues?.inspect
|
|
1542
|
+
? await base.provider.issues.inspect({ context: providerOperationContext(base.loadedContext), target: base.issueIdentity! })
|
|
1543
|
+
: unsupportedProviderOperation(base.provider, 'issues.inspect');
|
|
1544
|
+
if (!rereadIssue.ok) return softenFailure(providerPullRequestError(base, {
|
|
1545
|
+
...rereadIssue.error,
|
|
1546
|
+
code: 'IN_LABEL_SYNC_PARTIAL',
|
|
1547
|
+
message: `In-label synchronization is partial or unknown: ${rereadIssue.error.message}`
|
|
1548
|
+
}, base.prNumber));
|
|
1549
|
+
const actualIssueLabels = rereadIssue.value.labels.filter((label) => label.startsWith('in:')).sort();
|
|
1550
|
+
if (actualIssueLabels.join('\0') !== inLabels.join('\0')) return softenFailure(providerPullRequestError(base, {
|
|
1551
|
+
code: 'IN_LABEL_SYNC_PARTIAL',
|
|
1552
|
+
message: 'Issue in: labels did not converge after update',
|
|
1553
|
+
retryable: true
|
|
1554
|
+
}, base.prNumber));
|
|
1555
|
+
}
|
|
1556
|
+
const patch: Record<string, unknown> = {};
|
|
1557
|
+
for (const operation of planned) {
|
|
1558
|
+
if (operation.status !== 'planned') continue;
|
|
1559
|
+
if (operation.name === 'labels') patch.labels = operation.value;
|
|
1560
|
+
if (operation.name === 'assignees') patch.assignees = operation.value;
|
|
1561
|
+
if (operation.name === 'closing-issue') patch.body = operation.value;
|
|
1562
|
+
if (operation.name === 'milestone') patch.milestone = operation.value;
|
|
1563
|
+
}
|
|
1564
|
+
if (Object.keys(patch).length === 0) return softenFailure(result(planned.some((operation) => operation.status === 'skipped') ? 'degraded' : 'no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1565
|
+
platform: base.context.platform, capabilities: base.context.capabilities, resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: inspected.pullRequest, operations, error: null
|
|
1566
|
+
}));
|
|
1567
|
+
const updated = base.provider.changeRequests?.update
|
|
1568
|
+
? await base.provider.changeRequests.update({
|
|
1569
|
+
context: providerOperationContext(base.loadedContext),
|
|
1570
|
+
target: base.prIdentity,
|
|
1571
|
+
currentLabels: inspected.pullRequest.labels,
|
|
1572
|
+
patch: patch as never,
|
|
1573
|
+
mutation: { idempotencyKey: `pull-request:update:${base.resolved.taskId}` }
|
|
1574
|
+
})
|
|
1575
|
+
: unsupportedProviderOperation(base.provider, 'changeRequests.update');
|
|
1576
|
+
if (!updated.ok) {
|
|
1577
|
+
const error = issueChanged && !updated.error.code.startsWith('IN_LABEL_SYNC')
|
|
1578
|
+
? { ...updated.error, code: 'IN_LABEL_SYNC_PARTIAL', message: `In-label synchronization is partial or unknown: ${updated.error.message}` }
|
|
1579
|
+
: updated.error;
|
|
1580
|
+
return softenFailure(providerPullRequestError(base, error, base.prNumber));
|
|
1581
|
+
}
|
|
1582
|
+
let finalPullRequest = inspected.pullRequest;
|
|
1583
|
+
if (planned.some((operation) => operation.name === 'labels' && operation.status === 'planned')) {
|
|
1584
|
+
const reread = base.provider.changeRequests?.inspect
|
|
1585
|
+
? await base.provider.changeRequests.inspect({ context: providerOperationContext(base.loadedContext), target: base.prIdentity })
|
|
1586
|
+
: unsupportedProviderOperation(base.provider, 'changeRequests.inspect');
|
|
1587
|
+
if (!reread.ok) return softenFailure(providerPullRequestError(base, {
|
|
1588
|
+
...reread.error,
|
|
1589
|
+
code: 'IN_LABEL_SYNC_PARTIAL',
|
|
1590
|
+
message: `In-label synchronization is partial or unknown: ${reread.error.message}`
|
|
1591
|
+
}, base.prNumber));
|
|
1592
|
+
finalPullRequest = normalizeProviderPullRequest(reread.value, base.context.platform.repository!, base.prNumber || 0, inspected.pullRequest);
|
|
1593
|
+
const expected = ((planned.find((operation) => operation.name === 'labels')?.value as string[] || [])
|
|
1594
|
+
.filter((label) => label.startsWith('in:') || label.startsWith('type:'))).sort();
|
|
1595
|
+
const actual = finalPullRequest.labels.filter((label) => label.startsWith('in:') || label.startsWith('type:')).sort();
|
|
1596
|
+
if (actual.join('\0') !== expected.join('\0')) return softenFailure(providerPullRequestError(base, {
|
|
1597
|
+
code: 'IN_LABEL_SYNC_PARTIAL',
|
|
1598
|
+
message: 'Pull request labels did not converge after update',
|
|
1599
|
+
retryable: true
|
|
1600
|
+
}, base.prNumber));
|
|
1601
|
+
}
|
|
1602
|
+
return softenFailure(result(planned.some((operation) => operation.status === 'skipped') ? 'degraded' : 'applied', base.resolved.taskId, base.issueNumber, base.prNumber, {
|
|
1603
|
+
changed: issueChanged || updated.value.changed,
|
|
1604
|
+
platform: base.context.platform,
|
|
1605
|
+
capabilities: base.context.capabilities,
|
|
1606
|
+
resource: { kind: 'pull-request', number: base.prNumber },
|
|
1607
|
+
pullRequest: finalPullRequest,
|
|
1608
|
+
operations: operations.map((operation) => operation.status === 'planned' ? { ...operation, status: 'applied' } : operation),
|
|
1609
|
+
error: null
|
|
1078
1610
|
}));
|
|
1079
1611
|
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function readProjectPrFlow(repoRoot: string): 'required' | 'disabled' | undefined {
|
|
1615
|
+
try {
|
|
1616
|
+
const config = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8')) as { prFlow?: unknown };
|
|
1617
|
+
return config.prFlow === 'required' || config.prFlow === 'disabled' ? config.prFlow : undefined;
|
|
1618
|
+
} catch {
|
|
1619
|
+
return undefined;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function skipPlatformPullRequestFactUnlocked(
|
|
1624
|
+
resolved: Extract<ReturnType<typeof resolveTaskRef>, { ok: true }>,
|
|
1625
|
+
options: SkipFactOptions
|
|
1626
|
+
): PullRequestResult {
|
|
1627
|
+
let content: string;
|
|
1628
|
+
let frontmatter: ReturnType<typeof parseTypedTaskFrontmatter>;
|
|
1629
|
+
try {
|
|
1630
|
+
content = fs.readFileSync(resolved.taskMdPath, 'utf8');
|
|
1631
|
+
frontmatter = parseTypedTaskFrontmatter(content);
|
|
1632
|
+
} catch (error) {
|
|
1633
|
+
return result('failed', resolved.taskId, null, null, {
|
|
1634
|
+
error: { code: 'TASK_DOCUMENT_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
let issueIdentity: ResourceIdentity | null;
|
|
1638
|
+
try { issueIdentity = taskIssueIdentity(frontmatter, undefined, options.runtimeVersion); }
|
|
1639
|
+
catch (error) { return result('failed', resolved.taskId, null, null, { error: { ...taskIssueIdentityError(error), retryable: false } }); }
|
|
1640
|
+
const issueNumber = resourceIdentityNumber(issueIdentity);
|
|
1641
|
+
const existing = readPrDeliveryFact(frontmatter, options.runtimeVersion);
|
|
1642
|
+
if (existing.status === 'invalid') return result('failed', resolved.taskId, issueNumber, null, {
|
|
1643
|
+
error: { code: existing.error.code, message: existing.error.message, retryable: false }
|
|
1082
1644
|
});
|
|
1083
|
-
if (
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
operations:
|
|
1088
|
-
|
|
1645
|
+
if (existing.status === 'missing') return result('failed', resolved.taskId, issueNumber, null, {
|
|
1646
|
+
error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
|
|
1647
|
+
});
|
|
1648
|
+
if (existing.fact.state === 'skipped') return result('no-op', resolved.taskId, issueNumber, null, {
|
|
1649
|
+
operations: [{ name: 'task:skip-pr', status: 'no-op', reasonCode: null }],
|
|
1650
|
+
error: null
|
|
1651
|
+
});
|
|
1652
|
+
if (existing.fact.state === 'bound') {
|
|
1653
|
+
const boundNumber = resourceIdentityNumber(existing.fact.identity.resource);
|
|
1654
|
+
return result('failed', resolved.taskId, issueNumber, boundNumber, {
|
|
1655
|
+
error: { code: 'PR_DELIVERY_FACT_CONFLICT', message: `Task is already bound to PR ${boundNumber === null ? 'identity' : `#${boundNumber}`}`, retryable: false }
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
if (readProjectPrFlow(resolved.repoRoot) === 'required') return result('failed', resolved.taskId, issueNumber, null, {
|
|
1659
|
+
error: { code: 'PR_SKIP_FORBIDDEN', message: 'Cannot skip PR delivery when prFlow is required', retryable: false }
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
const metadata = captureTaskWriteMetadata();
|
|
1663
|
+
const fact = buildSkippedFact(factTimestamp(metadata.timestamp));
|
|
1664
|
+
const write = writeTask({
|
|
1665
|
+
taskRef: resolved.taskId,
|
|
1666
|
+
expectedState: 'active',
|
|
1667
|
+
dryRun: options.dryRun,
|
|
1668
|
+
mutations: [
|
|
1669
|
+
{ kind: 'frontmatter', set: { ...factFrontmatterMutation(fact).set, assigned_to: options.agent } },
|
|
1670
|
+
{ kind: 'section', aliases: ['活动日志', 'Activity Log'], heading: '活动日志', body: appendActivity(
|
|
1671
|
+
content, `- ${metadata.timestamp} — **Skip PR** by ${options.agent} — PR delivery explicitly skipped`
|
|
1672
|
+
) }
|
|
1673
|
+
]
|
|
1674
|
+
}, { repoRoot: resolved.repoRoot, metadataProvider: () => metadata });
|
|
1675
|
+
if (write.status === 'failed') return result('failed', resolved.taskId, issueNumber, null, {
|
|
1676
|
+
error: { code: write.error.code, message: write.error.message, retryable: false }
|
|
1677
|
+
});
|
|
1678
|
+
const status = options.dryRun ? 'planned' : write.status === 'no-op' ? 'no-op' : 'applied';
|
|
1679
|
+
return result(status, resolved.taskId, issueNumber, null, {
|
|
1680
|
+
changed: !options.dryRun && write.changed,
|
|
1681
|
+
operations: [{ name: 'task:skip-pr', status, reasonCode: null }],
|
|
1682
|
+
error: null
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
async function skipPlatformPullRequestFact(taskRef: string, options: SkipFactOptions): Promise<PullRequestResult> {
|
|
1687
|
+
const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
|
|
1688
|
+
if (!resolved.ok) return result('failed', resolved.taskId, null, null, {
|
|
1689
|
+
error: { code: resolved.code, message: resolved.message, retryable: false }
|
|
1690
|
+
});
|
|
1691
|
+
if (resolved.state !== 'active') return result('failed', resolved.taskId, null, null, {
|
|
1692
|
+
error: { code: 'TASK_STATE_INVALID', message: 'platform-pr skip requires an active task', retryable: false }
|
|
1693
|
+
});
|
|
1694
|
+
try {
|
|
1695
|
+
return withTaskExecutionLock(
|
|
1696
|
+
resolved.repoRoot,
|
|
1697
|
+
resolved.taskId,
|
|
1698
|
+
'platform-pr.skip',
|
|
1699
|
+
() => skipPlatformPullRequestFactUnlocked(resolved, options)
|
|
1700
|
+
);
|
|
1701
|
+
} catch (error) {
|
|
1702
|
+
if (error instanceof TaskExecutionLockError) return result('blocked', resolved.taskId, null, null, {
|
|
1703
|
+
error: { code: error.code, message: error.message, retryable: true }
|
|
1704
|
+
});
|
|
1705
|
+
return result('failed', resolved.taskId, null, null, {
|
|
1706
|
+
error: { code: 'PR_SKIP_FAILED', message: error instanceof Error ? error.message : String(error), retryable: false }
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1089
1709
|
}
|
|
1090
1710
|
|
|
1091
1711
|
export {
|
|
@@ -1096,11 +1716,12 @@ export {
|
|
|
1096
1716
|
inspectPlatformPullRequest,
|
|
1097
1717
|
inspectPlatformPullRequestByNumber,
|
|
1098
1718
|
normalizePullRequest,
|
|
1099
|
-
resolveGitHubChangeRequestGitEvidence,
|
|
1100
1719
|
resolveExternalPullRequest,
|
|
1720
|
+
skipPlatformPullRequestFact,
|
|
1101
1721
|
selectExternalPullRequest,
|
|
1102
1722
|
selectPullRequest,
|
|
1103
|
-
syncPlatformPullRequest
|
|
1723
|
+
syncPlatformPullRequest,
|
|
1724
|
+
syncPlatformPullRequestInLabels
|
|
1104
1725
|
};
|
|
1105
|
-
export type { BindOptions, CreateOptions, ExternalPullRequestResult, ExternalPullRequestSelection, PullRequestPrimaryResult, PullRequestResult, PullRequestSnapshot, ResolveExternalOptions, SyncOptions };
|
|
1726
|
+
export type { BindOptions, CreateOptions, ExternalPullRequestResult, ExternalPullRequestSelection, PullRequestPrimaryResult, PullRequestResult, PullRequestSnapshot, ResolveExternalOptions, SkipFactOptions, SyncOptions };
|
|
1106
1727
|
export { warningResultForPrimary };
|