From 521b1ff80a70140e286ced7500d0b150cf9e6d6c Mon Sep 17 00:00:00 2001 From: Changyong Gong Date: Fri, 9 Oct 2026 14:47:17 +0800 Subject: [PATCH 1/3] Generalize team-memory queue dispatch for cross-repository coordinators Preserve the IssueLens pilot payload and source artifact contract while adding validated explicit coordinator targets and independent target authentication. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/issuelens/README.md | 8 +- .github/actions/issuelens/issuelens_action.py | 101 ++++++-- .github/actions/queue-team-memory/README.md | 109 ++++++++- .github/actions/queue-team-memory/action.yml | 22 +- .github/copilot-instructions.md | 11 +- docs/guide.md | 10 + tests/test_team_memory_coordinator.py | 221 ++++++++++++++++++ tests/test_team_memory_workflow.py | 32 ++- 8 files changed, 475 insertions(+), 39 deletions(-) diff --git a/.github/actions/issuelens/README.md b/.github/actions/issuelens/README.md index 2b25af7..340878c 100644 --- a/.github/actions/issuelens/README.md +++ b/.github/actions/issuelens/README.md @@ -144,7 +144,13 @@ IssueLens's existing wiki configuration remains unchanged. Java tooling repositories are not enabled or modified by this pilot. Their future centralized requests will need authenticated cross-repository dispatch, source validation, and a separate queue for the shared -`microsoft/vscode-java-pack` wiki. Reusing a workflow or composite action alone +`microsoft/vscode-java-pack` wiki. The shared queue action now accepts explicit +`coordinator-repository`, `coordinator-workflow`, and `coordinator-ref` inputs +for that separate transport integration, with a four-input dispatch that adds +the authenticated `source_repository`. Its artifact schema stays identical; +its receiver owns source allowlists, provenance, privacy, and wiki scope. +Omitting the target inputs preserves this pilot's three-ID contract and defaults. +Reusing a workflow or composite action alone does not move its run into the coordinator repository. Use **Run workflow** on the coordinator with `pull_request_number` for a manual diff --git a/.github/actions/issuelens/issuelens_action.py b/.github/actions/issuelens/issuelens_action.py index 8f7f110..bb9b66b 100644 --- a/.github/actions/issuelens/issuelens_action.py +++ b/.github/actions/issuelens/issuelens_action.py @@ -77,8 +77,8 @@ def github_request(path, payload=None, *, token=None): ) -def github_read(path, payload=None): - request = github_request(path, payload) +def github_read(path, payload=None, *, token=None): + request = github_request(path, payload, token=token) with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response: data = response.read(4 * 1024 * 1024 + 1) require(len(data) <= 4 * 1024 * 1024, "GitHub response exceeds the preflight limit") @@ -469,18 +469,45 @@ def prepare_coordinated_memory(repository, event): return prepare_push_memory(repository, project, push, {**metadata, **coordinator_metadata(repository)}) -def prepare_dispatch(): +def dispatch_target(): + values = tuple(os.environ.get(name, "") for name in + ("COORDINATOR_REPOSITORY", "COORDINATOR_WORKFLOW", "COORDINATOR_REF")) + if not any(values): + return None + require(all(values), "Supply all three coordinator target inputs or omit all of them") + repository, workflow, reference = values + require(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}", + repository) and ".." not in repository, "Invalid coordinator-repository") + require(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\.ya?ml", workflow) + and ".." not in workflow, "coordinator-workflow must be a YAML workflow basename") + require(len(reference) <= 255 + and all(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", part) + and not part.endswith((".", ".lock")) for part in reference.split("/")) + and ".." not in reference and not reference.startswith("refs/"), + "coordinator-ref must be a simple branch name, not a full ref or revision expression") + return values + + +def dispatch_source_repository(target): repository = os.environ["GITHUB_REPOSITORY"] - require(repository.lower() == COORDINATOR_REPOSITORY.lower() - and os.environ["GITHUB_EVENT_NAME"] == "push", "Only IssueLens pushes may use this dispatcher") - event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8")) - require(event["repository"]["full_name"].lower() == repository.lower(), "Event repository mismatch") + require(os.environ["GITHUB_EVENT_NAME"] == "push", "Only pushes may use this dispatcher") + require(target is not None or repository.lower() == COORDINATOR_REPOSITORY.lower(), + "Only IssueLens pushes may use the default dispatcher") + require(os.environ["GH_TOKEN"].strip(), "source-token must be non-empty") + return repository + + +def dispatch_snapshot(repository, event): + require(type(event["repository"]["id"]) is int + and event["repository"]["full_name"].lower() == repository.lower(), "Event repository mismatch") project = validate_workflow(repository, event) + require(type(project["id"]) is int and project["id"] > 0 and project["full_name"] == repository, + "Source repository must match its canonical authenticated identity") require(os.environ["GITHUB_WORKFLOW_REF"] == repository + "/" + DISPATCH_WORKFLOW + "@refs/heads/" + project["default_branch"], "Unexpected dispatch workflow") before, after, shas = validate_push_event(event, os.environ["GITHUB_REF"], os.environ.get("GITHUB_SHA")) require(os.environ["GITHUB_WORKFLOW_SHA"] == after, "Source workflow revision does not match the push") - snapshot = { + return { "metadata": team_memory_metadata(repository, project, event), "event": { "repository": {"id": project["id"], "full_name": repository}, "ref": event["ref"], @@ -488,6 +515,13 @@ def prepare_dispatch(): "commits": [{"id": sha} for sha in shas], "head_commit": {"id": after}, }, } + + +def prepare_dispatch(): + target = dispatch_target() + repository = dispatch_source_repository(target) + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8")) + snapshot = dispatch_snapshot(repository, event) content = json.dumps(snapshot, separators=(",", ":")).encode("utf-8") require(len(content) <= MAX_SOURCE_BYTES, "Source event exceeds 64 KiB") path = source_event_path() @@ -498,27 +532,50 @@ def prepare_dispatch(): print("Prepared identity-only team-memory source event") +def validate_dispatch_target(target, token): + repository, workflow, reference = target + project = github_read(f"/repos/{repository}", token=token) + require(isinstance(project.get("full_name"), str) + and project["full_name"].lower() == repository.lower() + and type(project.get("id")) is int and project["id"] > 0, + "Coordinator repository identity mismatch") + selected = github_read(f"/repos/{repository}/actions/workflows/{workflow}", token=token) + require(type(selected.get("id")) is int and selected["id"] > 0 + and selected.get("path") == ".github/workflows/" + workflow and selected.get("state") == "active", + "Coordinator workflow identity mismatch or workflow is inactive") + branch = github_read(f"/repos/{repository}/branches/{urllib.parse.quote(reference, safe='')}", token=token) + require(branch.get("name") == reference and isinstance(branch.get("commit"), dict), + "Coordinator branch identity mismatch") + full_sha(branch["commit"].get("sha")) + + def dispatch(): - repository = os.environ["GITHUB_REPOSITORY"] - require(repository.lower() == COORDINATOR_REPOSITORY.lower() - and os.environ["GITHUB_EVENT_NAME"] == "push", "Only IssueLens pushes may use this dispatcher") + target = dispatch_target() + repository = dispatch_source_repository(target) dispatch_token = os.environ.get("DISPATCH_TOKEN", os.environ["GH_TOKEN"]) require(dispatch_token.strip(), "dispatch-token must be non-empty") snapshot = read_source_event() - metadata = snapshot["metadata"] - project = validate_workflow(repository, snapshot["event"]) - require(os.environ["GITHUB_WORKFLOW_REF"] == repository + "/" + DISPATCH_WORKFLOW - + "@refs/heads/" + project["default_branch"] - and metadata == team_memory_metadata(repository, project, snapshot["event"]), + require(isinstance(snapshot["metadata"], dict) + and all(type(snapshot["metadata"].get(name)) is int + for name in ("repository_id", "run_id", "run_attempt")), + "Invalid source dispatch metadata types") + require(snapshot == dispatch_snapshot(repository, snapshot["event"]), "Source dispatch metadata changed") - validate_push_event(snapshot["event"], os.environ["GITHUB_REF"], os.environ.get("GITHUB_SHA")) + metadata = snapshot["metadata"] artifact_id = positive(os.environ["SOURCE_ARTIFACT_ID"]) + inputs = { + "source_run_id": str(metadata["run_id"]), "source_run_attempt": str(metadata["run_attempt"]), + "source_artifact_id": str(artifact_id), + } + if target is None: + target = (repository, "team-memory-coordinator.yml", metadata["base_ref"]) + else: + validate_dispatch_target(target, dispatch_token) + inputs["source_repository"] = repository + coordinator, workflow, reference = target request = github_request( - f"/repos/{repository}/actions/workflows/team-memory-coordinator.yml/dispatches", - {"ref": metadata["base_ref"], "inputs": { - "source_run_id": str(metadata["run_id"]), "source_run_attempt": str(metadata["run_attempt"]), - "source_artifact_id": str(artifact_id), - }}, + f"/repos/{coordinator}/actions/workflows/{workflow}/dispatches", + {"ref": reference, "inputs": inputs}, token=dispatch_token, ) with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response: diff --git a/.github/actions/queue-team-memory/README.md b/.github/actions/queue-team-memory/README.md index 3810762..35b420a 100644 --- a/.github/actions/queue-team-memory/README.md +++ b/.github/actions/queue-team-memory/README.md @@ -4,12 +4,15 @@ A composite action that validates a trusted default-branch push, uploads its bounded identity-only source artifact, and sends one dispatch to the central team-memory coordinator. It does not log in to Azure or invoke IssueLens. -The current pilot accepts only `microsoft/IssueLens` pushes from +With all coordinator target inputs omitted, the current pilot accepts +only `microsoft/IssueLens` pushes from `.github/workflows/team-memory-post-merge.yml` and dispatches `.github/workflows/team-memory-coordinator.yml` in that same repository. -Packaging the dispatch sequence does not enable Java tooling repositories or -cross-repository sources; that rollout still needs a validated source allowlist, -cross-repository source/artifact access, and its own wiki-specific queue. +Supplying all three target inputs enables generic dispatch to an independently +validated coordinator repository, workflow, and branch. This does not enable workflows, +create credentials, or extend repository access. The receiver must own its +trusted source/workflow allowlist, artifact provenance checks, privacy and wiki +scope, queue, and final maintenance validation. ## Usage @@ -41,18 +44,107 @@ the hosted IssueLens App's private key. The action does not create credentials. | Input | Purpose | | --- | --- | | `source-token` | Source repository read access for provenance validation, including the validation immediately before dispatch. | -| `dispatch-token` | Actions write access to the coordinator repository, used only for the dispatch POST. An explicitly empty token fails instead of falling back. | +| `dispatch-token` | Coordinator metadata read access and Actions write access for the single dispatch POST. An explicitly empty token fails instead of falling back. | +| `coordinator-repository` | Coordinator `owner/repository`. Defaults to empty; supply all three target inputs together or omit all of them. | +| `coordinator-workflow` | YAML workflow basename, such as `team-memory-coordinator.yml`, not a path, URL, workflow ID, or display name. Defaults to empty. | +| `coordinator-ref` | Coordinator branch, independent of the source default branch. Defaults to empty. | The IssueLens source job retains `contents: read` and `actions: write` permissions. A source repository's `GITHUB_TOKEN` does not expand repository access -when its permissions change; future cross-repository callers must provide a +when its permissions change; cross-repository callers must provide a token authorized for the central repository. The coordinator will separately need Actions read access to allowed source repositories. +For generic target checks, a fine-grained/App dispatch token needs Contents +read access (branch lookup) as well as Actions write access (workflow lookup +and dispatch) on the coordinator repository. The action does not install an App +or modify those permissions. + +### Generic / Cross-Repository Dispatch + +This example targets Java Pack's coordinator on `main`; the source workflow +must still run from its own current default branch, which may be `develop`. +It belongs in `.github/workflows/team-memory-post-merge.yml`. The example is +an interface illustration, not workflow enablement or a credential setup step: + +```yaml +- name: Queue team-memory update + uses: microsoft/IssueLens/.github/actions/queue-team-memory@FULL_COMMIT_SHA + with: + source-token: ${{ github.token }} + dispatch-token: ${{ steps.dispatch-token.outputs.token }} + coordinator-repository: microsoft/vscode-java-pack + coordinator-workflow: team-memory-coordinator.yml + coordinator-ref: main +``` + +Target values use a deliberately simple ASCII schema: an owner (1-39 +alphanumeric/hyphen characters, starting and ending alphanumeric) and repository +(1-100 alphanumeric/dot/underscore/hyphen characters, starting alphanumeric); +a workflow basename starting alphanumeric with at most 100 characters before +`.yml` or `.yaml`; and a branch up to 255 characters whose slash-separated +components start alphanumeric and contain only alphanumeric/dot/underscore/hyphen. +`..`, empty branch components, components ending in `.` or `.lock`, and +`refs/`-prefixed branch names are rejected. Paths, URLs, query strings, and +revision expressions are not accepted. + +Target syntax is checked before artifact preparation and again before dispatch. +Immediately before the POST, authenticated reads with `dispatch-token` verify +the target repository identity, exact active workflow path, and branch identity +with a full nonzero commit SHA. Source validation independently uses +`source-token`; neither credential is substituted for the other. The branch +need not be the source branch or the target default branch; the receiver must +enforce its own allowed coordinator ref. + +Generic dispatch sends exactly four string inputs: `source_repository`, +`source_run_id`, `source_run_attempt`, and `source_artifact_id`. The source +repository comes only from `GITHUB_REPOSITORY`, verified against the event and +authenticated canonical repository identity; there is no caller-selectable +source repository input. The three IDs come from the original source run, +attempt, and upload output. An explicit target, including a same-repository +target, uses this four-input contract. Omitting all target inputs retains the +IssueLens pilot's existing three-ID payload and source default-branch ref; +its coordinator and the separate `issuelens` invocation action are unchanged. + +### Source Artifact Contract + +Both modes upload `issuelens-team-memory-source-${GITHUB_RUN_ATTEMPT}`, retained +for seven days, from +`${RUNNER_TEMP}/issuelens-team-memory-source/source-event.json`. The JSON is +bounded to 64 KiB and exactly two top-level keys, `metadata` and `event`: + +| Object | Exact fields | +| --- | --- | +| `metadata` | `repository`, `repository_id`, `base_ref`, `event_name`, `event_action`, `actor_login`, `triggering_actor`, `workflow_ref`, `workflow_sha`, `run_id`, `run_attempt` | +| `event` | `repository: {id, full_name}`, `ref`, `before`, `after`, `created`, `deleted`, `forced`, `commits: [{id}, ...]`, `head_commit: {id}` | + +Repository/run/attempt IDs are integers; the repository is the canonical source +name. `event_name` and `event_action` are `push`. `base_ref` is the source +default branch, `ref` is `refs/heads/` plus that branch, and `workflow_ref` +identifies that source's `.github/workflows/team-memory-post-merge.yml` at the +same branch. Actors are the workflow's `GITHUB_ACTOR` and +`GITHUB_TRIGGERING_ACTOR`. `workflow_sha`, `GITHUB_SHA`, `after`, and +`head_commit.id` must be the same full nonzero SHA. `before` must be a distinct +full nonzero SHA. The three push flags are exactly `false`. All 1-1000 supplied +commit IDs are retained in order, must be unique full nonzero SHAs, include +`after`, and exclude `before`. Empty/oversized inventories are rejected. +No target, token, body, message, or code fields are added. + +The dispatcher preserves the complete supplied push inventory; the receiver +must authenticate the source run/attempt and workflow path/identity, check the +artifact's immutable ID, exact name, origin, SHA-256 digest, size, retention and +expiry before downloading, and reject digest mismatches. It must then revalidate +the exact snapshot and the authoritative complete fast-forward range before +discovery/invocation. A nonempty but truncated event inventory is not proof of +completeness. Upload or dispatch acceptance alone is not trusted source evidence +or write authorization. ## Execution and Outcomes The action runs preparation, pinned artifact upload, and one bounded dispatch in order. Failure stops the sequence; there is no retry or agent invocation. +Every GitHub HTTP operation is bounded, rejects redirects, and uses a 30-second +transport timeout. Target authentication failures stop before the dispatch POST; +an artifact may already exist at that point. The artifact contains only source identities, not source code, commit messages, issue/PR bodies, agent responses, or credentials, and expires after seven days. The shared standard-library helper is resolved relative to `github.action_path` @@ -65,8 +157,9 @@ neither dispatch acceptance nor maintenance completion. A transport error may leave dispatch outcome unknown; inspect coordinator runs before retrying. The composite action executes in the caller repository. The actual queue lives -in the [central coordinator workflow](../../workflows/team-memory-coordinator.yml), -not in the action: one active run and at most 100 pending runs in +in the selected coordinator workflow, not in the action. The unchanged +[IssueLens coordinator](../../workflows/team-memory-coordinator.yml) allows +one active run and at most 100 pending runs in `issuelens-team-memory-wiki-microsoft-IssueLens`, with `queue: max` and no in-progress cancellation. Only that coordinator invokes IssueLens and validates the final maintenance receipt. This does not serialize chat or external direct diff --git a/.github/actions/queue-team-memory/action.yml b/.github/actions/queue-team-memory/action.yml index 55378f0..a962d31 100644 --- a/.github/actions/queue-team-memory/action.yml +++ b/.github/actions/queue-team-memory/action.yml @@ -1,5 +1,5 @@ name: Queue IssueLens team memory -description: Preserve a validated IssueLens push and dispatch its centrally queued team-memory workflow. +description: Preserve a validated default-branch push and dispatch its centrally queued team-memory workflow. inputs: source-token: @@ -7,9 +7,21 @@ inputs: required: false default: ${{ github.token }} dispatch-token: - description: Token with Actions write access to the IssueLens coordinator repository. + description: Token for coordinator metadata reads and the single Actions dispatch POST. required: false default: ${{ github.token }} + coordinator-repository: + description: Coordinator owner/repository; supply all three coordinator inputs to enable generic dispatch. + required: false + default: "" + coordinator-workflow: + description: Coordinator workflow basename (for example team-memory-coordinator.yml); omit all target inputs for the IssueLens pilot. + required: false + default: "" + coordinator-ref: + description: Coordinator branch, independent of the source default branch; required with the other target inputs. + required: false + default: "" outputs: source-artifact-id: @@ -25,6 +37,9 @@ runs: env: GITHUB_ACTION_PATH: ${{ github.action_path }} GH_TOKEN: ${{ inputs.source-token }} + COORDINATOR_REPOSITORY: ${{ inputs.coordinator-repository }} + COORDINATOR_WORKFLOW: ${{ inputs.coordinator-workflow }} + COORDINATOR_REF: ${{ inputs.coordinator-ref }} run: python3 -I "$GITHUB_ACTION_PATH/../issuelens/issuelens_action.py" prepare-dispatch - name: Preserve source event for the coordinator @@ -43,4 +58,7 @@ runs: GH_TOKEN: ${{ inputs.source-token }} DISPATCH_TOKEN: ${{ inputs.dispatch-token }} SOURCE_ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }} + COORDINATOR_REPOSITORY: ${{ inputs.coordinator-repository }} + COORDINATOR_WORKFLOW: ${{ inputs.coordinator-workflow }} + COORDINATOR_REF: ${{ inputs.coordinator-ref }} run: python3 -I "$GITHUB_ACTION_PATH/../issuelens/issuelens_action.py" dispatch diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 223c4fb..30ae4ee 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -327,7 +327,16 @@ Actions write access, but no Azure credentials or agent invocation. The queue action separates `source-token` reads from the `dispatch-token` POST, defaulting both to `github.token` for this same-repository pilot. It resolves the existing `issuelens` Python helper from the same pinned action bundle, not the caller's -checkout. Hosting the action centrally does not relocate its execution; the +checkout. Explicit `coordinator-repository`, `coordinator-workflow` (YAML basename), +and `coordinator-ref` (branch) inputs activate generic dispatch, supplied all +together. Generic mode independently validates the target with `dispatch-token` +and adds authenticated `source_repository` to its dispatch inputs, never to the +unchanged identity-only artifact. The coordinator branch is independent of the +source default branch. Omitting all target inputs preserves the IssueLens pilot's +three-ID payload and defaults. Receiving coordinators own their source/workflow +allowlists, artifact provenance, privacy/wiki scope, and final outcome validation; +target inputs and tokens grant no new access or authority. +Hosting the action centrally does not relocate its execution; the actual concurrency queue remains in the dispatched coordinator workflow. The coordinator verifies the trusted source workflow/run and artifact provenance before download; the pinned downloader rejects digest mismatches. The shared diff --git a/docs/guide.md b/docs/guide.md index 18672f1..accf6cc 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -256,6 +256,16 @@ Java tooling rollout is separate: those repositories share the `microsoft/vscode-java-pack` wiki and will need their own central queue and authenticated source adapter. The existing direct-action consumers continue working unchanged, but are not serialized by this pilot. +The shared queue action supports explicit `coordinator-repository`, +`coordinator-workflow` (YAML basename), and `coordinator-ref` (branch) inputs, +supplied together. It validates target identities using the dispatch token and +adds `source_repository` to generic dispatches without changing the source +artifact. The coordinator branch is independent of the source default branch. +See the [queue action contract](../.github/actions/queue-team-memory/README.md) +for the exact input and artifact schemas. Receivers still own source allowlists, +artifact/range revalidation, privacy, wiki scope, and maintenance outcomes; +this transport interface does not enable other workflows or provide credentials. +Omitting all target inputs preserves the IssueLens pilot unchanged. Only PRs merged into the current default branch are accepted. Manual **Run workflow** now uses the coordinator, with a positive `pull_request_number` diff --git a/tests/test_team_memory_coordinator.py b/tests/test_team_memory_coordinator.py index 84e398a..0048d28 100644 --- a/tests/test_team_memory_coordinator.py +++ b/tests/test_team_memory_coordinator.py @@ -2,6 +2,8 @@ import json import pathlib import unittest +import urllib.error +from unittest.mock import patch import test_team_memory_workflow as memory_tests @@ -414,5 +416,224 @@ def test_coordinator_preflight_revalidates_after_source_download(self): self.token.assert_not_called() +class TeamMemoryGenericDispatchTests(unittest.TestCase): + execute = memory_tests.TeamMemoryActionTests.execute + action_outputs = memory_tests.TeamMemoryActionTests.action_outputs + responses = TeamMemoryCoordinatorTests.responses + write_source = TeamMemoryCoordinatorTests.write_source + + def setUp(self): + TeamMemoryCoordinatorTests.setUp(self) + self.repository = "microsoft/vscode-gradle" + self.project.update(full_name=self.repository, default_branch="develop") + self.source_metadata.update( + repository=self.repository, base_ref="develop", + workflow_ref=self.repository + "/" + action.DISPATCH_WORKFLOW + "@refs/heads/develop", + ) + self.push.update(repository={"id": 100, "full_name": self.repository}, ref="refs/heads/develop") + TeamMemoryCoordinatorTests.select_dispatcher(self) + self.environment.update( + GITHUB_REPOSITORY=self.repository, GITHUB_REF="refs/heads/develop", + COORDINATOR_REPOSITORY="microsoft/vscode-java-pack", + COORDINATOR_WORKFLOW="team-memory-coordinator.yml", COORDINATOR_REF="main", + DISPATCH_TOKEN="fake-dispatch-token", + ) + self.target_project = {"id": 200, "full_name": "microsoft/vscode-java-pack", "default_branch": "main"} + self.target_workflow = {"id": 300, "path": action.COORDINATOR_WORKFLOW, "state": "active"} + self.target_branch = {"name": "main", "commit": {"sha": self.tip}} + + def target_responses(self): + return self.responses(self.target_project, self.target_workflow, self.target_branch) + + def acknowledgement(self, status=204): + response = Response(b"") + response.status = status + return response + + def test_generic_artifact_preserves_exact_consumer_schema_and_full_push(self): + self.event["commits"][0]["message"] = "UNTRUSTED_COMMIT_TEXT" + self.event["repository"]["description"] = "UNTRUSTED_DESCRIPTION" + self.execute("prepare-dispatch", self.responses(self.project)) + path = pathlib.Path(self.action_outputs()["source-event-path"]) + self.assertEqual(path, self.directory / "issuelens-team-memory-source" / "source-event.json") + self.assertEqual(json.loads(path.read_bytes()), self.snapshot) + self.assertLessEqual(len(path.read_bytes()), 64 * 1024) + self.assertEqual([item["id"] for item in self.snapshot["event"]["commits"]], [self.merge_sha, self.after]) + for excluded in ("UNTRUSTED", "fake-", "coordinator", "source_repository"): + self.assertNotIn(excluded, path.read_text()) + self.opener.open.assert_called_once() + self.token.assert_not_called() + + def test_cross_repo_dispatch_authenticates_target_and_keeps_develop_separate_from_main(self): + path = self.write_source() + with patch.object(action, "github_request", wraps=action.github_request) as requests: + self.execute("dispatch", self.responses(self.project) + self.target_responses() + [self.acknowledgement()]) + calls = self.opener.open.call_args_list + self.assertEqual([call.args[0].full_url for call in calls], [ + "https://api.github.com/repos/microsoft/vscode-gradle", + "https://api.github.com/repos/microsoft/vscode-java-pack", + "https://api.github.com/repos/microsoft/vscode-java-pack/actions/workflows/team-memory-coordinator.yml", + "https://api.github.com/repos/microsoft/vscode-java-pack/branches/main", + "https://api.github.com/repos/microsoft/vscode-java-pack/actions/workflows/team-memory-coordinator.yml/dispatches", + ]) + self.assertEqual([call.kwargs["token"] for call in requests.call_args_list], + [None] + [self.environment["DISPATCH_TOKEN"]] * 4) + self.assertEqual([call.args[0].get_method() for call in calls], ["GET"] * 4 + ["POST"]) + self.assertTrue(all(call.kwargs["timeout"] == 30 for call in calls)) + request = calls[-1].args[0] + self.assertEqual(json.loads(request.data), { + "ref": "main", "inputs": { + "source_repository": self.repository, "source_run_id": "123456", + "source_run_attempt": "2", "source_artifact_id": "456", + }, + }) + self.assertIsNone(self.builder.call_args.args[0].redirect_request(None, None, None, None, None, None)) + for token in (self.environment["GH_TOKEN"], self.environment["DISPATCH_TOKEN"]): + self.assertNotIn(token.encode(), request.data) + self.assertNotIn(token, path.read_text() + self.output.getvalue()) + self.token.assert_not_called() + + def test_generic_commit_inventory_exact_limit_and_artifact_budget(self): + self.event["commits"] = [{"id": f"{index:040x}"} for index in range(1, action.MAX_PUSH_COMMITS)] + self.event["commits"].append({"id": self.after}) + self.execute("prepare-dispatch", self.responses(self.project)) + path = pathlib.Path(self.action_outputs()["source-event-path"]) + content = path.read_bytes() + self.assertLessEqual(len(content), 64 * 1024) + self.assertEqual(len(json.loads(content)["event"]["commits"]), 1000) + (self.directory / "output.txt").unlink() + path.unlink() + self.event["commits"].append({"id": f"{action.MAX_PUSH_COMMITS:040x}"}) + with self.assertRaisesRegex(SystemExit, "inventory"): + self.execute("prepare-dispatch", self.responses(self.project)) + self.assertFalse(path.exists()) + self.assertFalse((self.directory / "output.txt").exists()) + self.token.assert_not_called() + + def test_explicit_same_repository_target_still_uses_generic_four_input_contract(self): + self.environment["COORDINATOR_REPOSITORY"] = self.repository + self.target_project = self.project + self.environment["COORDINATOR_REF"] = "develop" + self.target_branch["name"] = "develop" + self.write_source() + self.execute("dispatch", self.responses(self.project) + self.target_responses() + [self.acknowledgement()]) + payload = json.loads(self.opener.open.call_args.args[0].data) + self.assertEqual(payload["ref"], "develop") + self.assertEqual(payload["inputs"]["source_repository"], self.repository) + + def test_target_branch_path_is_url_encoded_and_not_used_as_source_branch(self): + self.environment["COORDINATOR_REF"] = "release/1.0" + self.target_branch["name"] = "release/1.0" + self.write_source() + self.execute("dispatch", self.responses(self.project) + self.target_responses() + [self.acknowledgement()]) + self.assertTrue(self.opener.open.call_args_list[-2].args[0].full_url.endswith("/branches/release%2F1.0")) + self.assertEqual(json.loads(self.opener.open.call_args.args[0].data)["ref"], "release/1.0") + + def test_invalid_and_partial_targets_fail_before_reads_upload_or_dispatch(self): + original = self.environment.copy() + invalid = { + "COORDINATOR_REPOSITORY": ("", "https://github.com/a/b", "a/b/c", "../repo", "a/b?x", "a/b\n", "-a/b"), + "COORDINATOR_WORKFLOW": ("", ".github/workflows/team.yml", "../team.yml", "team.yml/dispatches", + "team.json", "team.yml?x", "team.yml\n", "team;echo.yml"), + "COORDINATOR_REF": ("", "refs/heads/main", "../main", "main..next", "main.lock", "main\n", + "main?x", "main@{0}", "main;echo", "/main", "main//next", "main/", "x" * 256), + } + for name, values in invalid.items(): + for value in values: + for command in ("prepare-dispatch", "dispatch"): + with self.subTest(name=name, value=value, command=command): + self.environment = {**original, name: value} + with self.assertRaises(SystemExit): + self.execute(command, []) + self.opener.open.assert_not_called() + self.token.assert_not_called() + self.assertFalse((self.directory / "output.txt").exists()) + self.assertFalse((self.directory / "issuelens-team-memory-source").exists()) + + def test_target_authentication_and_identity_failures_stop_before_post(self): + self.write_source() + cases = [ + ([{**self.target_project, "full_name": "other/repo"}], 2), + ([{**self.target_project, "id": True}], 2), + ([self.target_project, {**self.target_workflow, "path": ".github/workflows/other.yml"}], 3), + ([self.target_project, {**self.target_workflow, "state": "disabled_manually"}], 3), + ([self.target_project, self.target_workflow, {**self.target_branch, "name": "develop"}], 4), + ([self.target_project, self.target_workflow, {"name": "main", "commit": {"sha": "short"}}], 4), + ] + for values, count in cases: + with self.subTest(values=values): + with self.assertRaises(SystemExit): + self.execute("dispatch", self.responses(self.project, *values)) + self.assertEqual(self.opener.open.call_count, count) + self.assertTrue(all(call.args[0].get_method() == "GET" for call in self.opener.open.call_args_list)) + for status in (302, 403, 404, 500): + with self.subTest(status=status): + failure = urllib.error.HTTPError("https://api.github.com", status, "PRIVATE DETAIL", {}, None) + with self.assertRaises(SystemExit) as raised: + self.execute("dispatch", self.responses(self.project) + [failure]) + self.assertNotIn("PRIVATE DETAIL", str(raised.exception)) + self.assertEqual(self.opener.open.call_count, 2) + self.token.assert_not_called() + + def test_dispatch_revalidates_source_snapshot_before_target_reads_or_writes(self): + original_snapshot, original_environment = copy.deepcopy(self.snapshot), self.environment.copy() + for change in ("workflow_sha", "workflow_path", "branch", "repository", "metadata", "body", + "created", "deleted", "forced", "duplicate", "truncated", "inventory_limit", "oversized"): + with self.subTest(change=change): + self.snapshot, self.environment = copy.deepcopy(original_snapshot), original_environment.copy() + if change == "workflow_sha": + self.environment["GITHUB_WORKFLOW_SHA"] = self.tip + elif change == "workflow_path": + self.environment["GITHUB_WORKFLOW_REF"] = self.repository + "/.github/workflows/other.yml@refs/heads/develop" + elif change == "branch": + self.environment["GITHUB_REF"] = "refs/heads/main" + elif change == "repository": + self.snapshot["event"]["repository"]["id"] = 101 + elif change == "metadata": + self.snapshot["metadata"]["run_attempt"] = True + elif change == "body": + self.snapshot["event"]["commits"][0]["message"] = "UNTRUSTED" + elif change in ("created", "deleted", "forced"): + self.snapshot["event"][change] = True + elif change == "duplicate": + self.snapshot["event"]["commits"].append({"id": self.after}) + elif change == "truncated": + self.snapshot["event"]["commits"] = [] + elif change == "inventory_limit": + self.snapshot["event"]["commits"] *= action.MAX_PUSH_COMMITS + self.write_source(b"x" * (action.MAX_SOURCE_BYTES + 1) if change == "oversized" else None) + with self.assertRaises(SystemExit): + self.execute("dispatch", self.responses(self.project)) + self.assertLessEqual(self.opener.open.call_count, 1) + self.assertTrue(all(call.args[0].get_method() == "GET" for call in self.opener.open.call_args_list)) + self.token.assert_not_called() + + def test_empty_tokens_and_invalid_artifact_ids_never_dispatch(self): + original = self.environment.copy() + self.write_source() + for changes in ({"GH_TOKEN": ""}, {"DISPATCH_TOKEN": ""}, {"DISPATCH_TOKEN": " \t"}, + {"SOURCE_ARTIFACT_ID": "0"}, {"SOURCE_ARTIFACT_ID": "1;echo"}, {"SOURCE_ARTIFACT_ID": "01"}): + with self.subTest(changes=changes): + self.environment = {**original, **changes} + with self.assertRaises(SystemExit): + self.execute("dispatch", self.responses(self.project)) + self.assertLessEqual(self.opener.open.call_count, 1) + self.token.assert_not_called() + + def test_unacknowledged_or_unknown_generic_dispatch_is_not_retried(self): + self.write_source() + for response in (self.acknowledgement(202), self.acknowledgement(302), OSError("PRIVATE DETAIL"), + urllib.error.HTTPError("https://api.github.com", 403, "PRIVATE DETAIL", {}, None)): + with self.subTest(response=response): + with self.assertRaises(SystemExit) as raised: + self.execute("dispatch", self.responses(self.project) + self.target_responses() + [response]) + self.assertNotIn("PRIVATE DETAIL", str(raised.exception)) + self.assertEqual(self.opener.open.call_count, 5) + self.assertEqual(sum(call.args[0].get_method() == "POST" + for call in self.opener.open.call_args_list), 1) + self.assertNotIn("accepted", self.output.getvalue()) + self.token.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_team_memory_workflow.py b/tests/test_team_memory_workflow.py index 9aa984d..c77730b 100644 --- a/tests/test_team_memory_workflow.py +++ b/tests/test_team_memory_workflow.py @@ -139,17 +139,21 @@ def test_queue_action_owns_preparation_upload_and_single_dispatch(self): "path": "${{ steps.source.outputs.source-event-path }}", "if-no-files-found": "error", "retention-days": "7", }) - self.assertEqual(set(self.queue_metadata["inputs"]), {"source-token", "dispatch-token"}) - for input_metadata in self.queue_metadata["inputs"].values(): - self.assertEqual(input_metadata["default"], "${{ github.token }}") + targets = ("coordinator-repository", "coordinator-workflow", "coordinator-ref") + self.assertEqual(set(self.queue_metadata["inputs"]), {"source-token", "dispatch-token", *targets}) + for name, input_metadata in self.queue_metadata["inputs"].items(): + self.assertEqual(input_metadata["default"], "" if name in targets else "${{ github.token }}") self.assertEqual(input_metadata["required"], "false") + target_env = {name.upper().replace("-", "_"): "${{ inputs." + name + " }}" for name in targets} self.assertEqual(prepare["env"], { "GITHUB_ACTION_PATH": "${{ github.action_path }}", "GH_TOKEN": "${{ inputs.source-token }}", + **target_env, }) self.assertEqual(dispatch["env"], { "GITHUB_ACTION_PATH": "${{ github.action_path }}", "GH_TOKEN": "${{ inputs.source-token }}", "DISPATCH_TOKEN": "${{ inputs.dispatch-token }}", "SOURCE_ARTIFACT_ID": "${{ steps.artifact.outputs.artifact-id }}", + **target_env, }) for step, command in ((prepare, "prepare-dispatch"), (dispatch, "dispatch")): self.assertEqual(step["shell"], "bash") @@ -240,11 +244,29 @@ def test_queue_action_remote_example_is_pinned_and_preserves_pilot_scope(self): }) self.assertNotIn("actions/checkout", example) self.assertIn("only `microsoft/IssueLens`", guide) - self.assertIn("does not enable Java", guide) + self.assertIn("does not enable workflows", guide) self.assertIn("executes in the caller", guide) self.assertIn("Actions write", guide) self.assertIn("does not expand repository access", guide) + def test_queue_action_generic_example_matches_the_four_input_contract(self): + guide = (QUEUE_ACTION_DIR / "README.md").read_text(encoding="utf-8") + example = guide.split("```yaml\n")[2].split("```", 1)[0] + invocation = yaml.load(example, Loader=yaml.BaseLoader)[0] + self.assertEqual(invocation["uses"], "microsoft/IssueLens/.github/actions/queue-team-memory@FULL_COMMIT_SHA") + self.assertEqual(invocation["with"], { + "source-token": "${{ github.token }}", "dispatch-token": "${{ steps.dispatch-token.outputs.token }}", + "coordinator-repository": "microsoft/vscode-java-pack", + "coordinator-workflow": "team-memory-coordinator.yml", "coordinator-ref": "main", + }) + self.assertEqual(set(invocation["with"]), set(self.queue_metadata["inputs"])) + for field in ("source_repository", "source_run_id", "source_run_attempt", "source_artifact_id"): + self.assertIn(f"`{field}`", guide) + self.assertIn("develop", guide) + self.assertIn("workflow_sha", guide) + self.assertIn("SHA-256 digest", guide) + self.assertNotIn("actions/checkout", example) + def test_agents_do_not_depend_on_the_workflow_contract(self): orchestrator = (ROOT / "agents" / "issuelens.md").read_text(encoding="utf-8") writer = (ROOT / "agents" / "team-memory.md").read_text(encoding="utf-8") @@ -575,7 +597,7 @@ def test_queue_helper_runs_from_downloaded_bundle_outside_checkout(self): cwd=caller, env=self.environment, capture_output=True, text=True, timeout=15, ) self.assertEqual(result.returncode, 1) - self.assertIn("Only IssueLens pushes", result.stderr) + self.assertIn("Only pushes", result.stderr) self.assertNotIn("ImportError", result.stderr) self.assertEqual(list(caller.iterdir()), []) self.assertFalse((self.directory / "output.txt").exists()) From 296350903a578f3bcd847b34ce85b51e90b4b919 Mon Sep 17 00:00:00 2001 From: Changyong Gong Date: Fri, 9 Oct 2026 16:27:43 +0800 Subject: [PATCH 2/3] Separate generic workflow dispatch from team-memory request preparation Keep push artifacts request-owned and move coordinator source verification and pinned downloads into the shared invocation action before Azure login. Make coordinator dispatch self-contained and caller-payload driven, with no sibling scripts or job-specific behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/issuelens/README.md | 76 ++-- .github/actions/issuelens/action.yml | 27 ++ .github/actions/issuelens/issuelens_action.py | 121 ++---- .github/actions/queue-team-memory/README.md | 240 +++++------- .github/actions/queue-team-memory/action.yml | 59 +-- .github/actions/queue-team-memory/dispatch.py | 133 +++++++ .github/copilot-instructions.md | 46 +-- .github/workflows/ci.yml | 2 +- .github/workflows/team-memory-coordinator.yml | 22 -- .github/workflows/team-memory-post-merge.yml | 22 ++ CONTRIBUTING.md | 5 +- docs/guide.md | 37 +- github_app_mcp/README.md | 15 +- tests/test_ci_workflow.py | 2 +- tests/test_issue_triage_workflow.py | 4 +- tests/test_team_memory_coordinator.py | 359 ++++-------------- tests/test_team_memory_workflow.py | 91 ++--- tests/test_workflow_dispatch_action.py | 216 +++++++++++ 18 files changed, 756 insertions(+), 721 deletions(-) create mode 100644 .github/actions/queue-team-memory/dispatch.py create mode 100644 tests/test_workflow_dispatch_action.py diff --git a/.github/actions/issuelens/README.md b/.github/actions/issuelens/README.md index 340878c..2f84884 100644 --- a/.github/actions/issuelens/README.md +++ b/.github/actions/issuelens/README.md @@ -5,8 +5,11 @@ It prepares and validates the request, authenticates to an existing IssueLens agent with Azure OIDC, and invokes it through a shared bounded HTTP/SSE client. It is a client of the agent, not part of the hosted agent runtime. -The action runs three steps: prepare the request, log in with the pinned -`azure/login` action, and submit the request. The standard-library Python helper +The normal path prepares the request, logs in with the pinned `azure/login` +action, and submits the request. Coordinated team-memory inputs first run +source verification and pinned artifact download inside this action; all +verification, download, and complete-range discovery precede Azure login. +The standard-library Python helper owns metadata validation, the caller's task text, bounded HTTP/SSE processing, and result validation. No inline Python, pip installation, container build, or GitHub App private key is required. @@ -100,33 +103,60 @@ local-action step to consumer repositories; use the remote reference above. ### IssueLens Coordinator Pilot IssueLens itself now uses two workflows. The -[push dispatcher](../../workflows/team-memory-post-merge.yml) calls the separate -[queue-team-memory action](../queue-team-memory/README.md), which owns source -preparation, artifact upload, and dispatch without Azure or agent credentials. -Its `source-token` and `dispatch-token` inputs separate source reads from the -coordinator dispatch POST; both default to `github.token` for this same-repository -pilot. The wrapper reuses this directory's Python helper through an action-relative -path, so remote references need no caller checkout. The action validates a -default-branch push and uploads only its repository, source run/attempt, -workflow revision, before/after SHAs, and commit IDs. It does not upload commit +[push workflow](../../workflows/team-memory-post-merge.yml) prepares the source +with this directory's request-owned `issuelens_action.py prepare-source` +helper and uploads the artifact with a pinned uploader, then calls the separate +[standalone dispatch action](../queue-team-memory/README.md). +The generic dispatcher owns only target validation and one POST with +caller-supplied workflow inputs. It never calls this action's scripts. +Source preparation uses the source read token; dispatch uses its independent +target token. Both are `github.token` for this same-repository pilot. +The source helper validates the authenticated canonical repository, exact +`.github/workflows/team-memory-post-merge.yml` on its current default branch, +and push/workflow SHA, and preserves only repository, source run/attempt, +workflow revision, before/after SHAs, and all commit IDs. It does not upload commit messages, source code, issue/PR bodies, agent responses, or credentials. The immutable artifact is named per run attempt and retained for seven days. -The dispatcher sends one `workflow_dispatch` to the coordinator on the trusted +The source workflow supplies three string IDs to the standalone dispatcher's +`workflow-inputs`; it sends one `workflow_dispatch` to the coordinator on the default branch, passing only source run, attempt, and artifact IDs. Its -repository-scoped `GITHUB_TOKEN` needs `actions: write`; it has no Azure secrets, +repository-scoped `GITHUB_TOKEN` needs Contents read and Actions write; it has no Azure secrets, OIDC permission, or agent invocation. -The coordinator accepts either those three identifiers or one manual -`pull_request_number`, never both. Before download it verifies the source +The coordinator owns the central queue and calls only this invocation action +(plus trusted sparse checkout for the local action). It accepts either those +three identifiers or one manual `pull_request_number`, never both. +Inside this action, source validation and pinned download precede normal +preflight and Azure login. Before download, the request-owned helper verifies the source repository, exact dispatcher path, push event, default branch, run attempt, full head SHA, and the artifact's run identity, name, digest, size, and expiry. -The pinned downloader rejects digest mismatches. Action preflight independently +The pinned downloader rejects digest mismatches. Normal preflight independently revalidates the source and reads the bounded identity-only artifact, then runs the same complete push discovery as direct callers. It preserves the original push range and source provenance even if the coordinator starts at a newer default-branch revision. Queue admission is not merge verification or proof of wiki publication. +The artifact remains +`${RUNNER_TEMP}/issuelens-team-memory-source/source-event.json`, uploaded as +`issuelens-team-memory-source-${GITHUB_RUN_ATTEMPT}` with seven-day retention. +JSON is at most 64 KiB, with exactly `metadata` and `event`: + +| Object | Exact fields | +| --- | --- | +| `metadata` | `repository`, `repository_id`, `base_ref`, `event_name`, `event_action`, `actor_login`, `triggering_actor`, `workflow_ref`, `workflow_sha`, `run_id`, `run_attempt` | +| `event` | `repository: {id, full_name}`, `ref`, `before`, `after`, `created`, `deleted`, `forced`, `commits: [{id}, ...]`, `head_commit: {id}` | + +Repository/run/attempt IDs are integers; repository names are canonical. +Event name/action are `push`, flags are exactly `false`, and `ref` names the +source default branch. `workflow_sha`, `GITHUB_SHA`, `after`, and `head_commit.id` +match one full nonzero SHA. `before` is distinct. All 1-1000 commit IDs are +retained in order, unique full nonzero SHAs, including `after` and excluding +`before`. Actors and workflow identity come from trusted workflow context. +No target, token, body, message, or code fields are added. The source run API +does not establish original `before`; the receiver must verify this preserved +inventory against the complete authoritative fast-forward range before invocation. + Automatic pushes and manual PR requests share the fixed `issuelens-team-memory-wiki-microsoft-IssueLens` concurrency group, with `queue: max` and `cancel-in-progress: false`. The slot covers the complete @@ -144,13 +174,13 @@ IssueLens's existing wiki configuration remains unchanged. Java tooling repositories are not enabled or modified by this pilot. Their future centralized requests will need authenticated cross-repository dispatch, source validation, and a separate queue for the shared -`microsoft/vscode-java-pack` wiki. The shared queue action now accepts explicit -`coordinator-repository`, `coordinator-workflow`, and `coordinator-ref` inputs -for that separate transport integration, with a four-input dispatch that adds -the authenticated `source_repository`. Its artifact schema stays identical; -its receiver owns source allowlists, provenance, privacy, and wiki scope. -Omitting the target inputs preserves this pilot's three-ID contract and defaults. -Reusing a workflow or composite action alone +`microsoft/vscode-java-pack` wiki. The standalone dispatch action accepts generic +targets and caller-owned `workflow-inputs`, but does not prepare evidence or +infer `source_repository`. Java Pack's existing immutable pin remains unchanged; +its later migration must move preparation/upload into the source request path +and keep receiving provenance validation. This action's coordinated adapter +still accepts only the IssueLens pilot; it does not silently generalize wiki +scope. Reusing a workflow or composite action alone does not move its run into the coordinator repository. Use **Run workflow** on the coordinator with `pull_request_number` for a manual diff --git a/.github/actions/issuelens/action.yml b/.github/actions/issuelens/action.yml index 5a5fd5e..3282aaa 100644 --- a/.github/actions/issuelens/action.yml +++ b/.github/actions/issuelens/action.yml @@ -83,6 +83,33 @@ outputs: runs: using: composite steps: + - name: Validate coordinated team-memory source + id: source + if: >- + inputs.request-type == 'team-memory' && + (inputs.source-run-id != '' || inputs.source-run-attempt != '' || inputs.source-artifact-id != '') + shell: bash + env: + GITHUB_ACTION_PATH: ${{ github.action_path }} + GH_TOKEN: ${{ inputs.github-token }} + REQUEST_TYPE: ${{ inputs.request-type }} + SOURCE_RUN_ID: ${{ inputs.source-run-id }} + SOURCE_RUN_ATTEMPT: ${{ inputs.source-run-attempt }} + SOURCE_ARTIFACT_ID: ${{ inputs.source-artifact-id }} + DISPATCH_PR: ${{ inputs.pull-request-number }} + run: python3 -I "$GITHUB_ACTION_PATH/issuelens_action.py" validate-source + + - name: Download verified team-memory source + if: steps.source.outputs.automatic == 'true' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ steps.source.outputs.source-artifact-id }} + run-id: ${{ steps.source.outputs.source-run-id }} + repository: ${{ github.repository }} + github-token: ${{ inputs.github-token }} + path: ${{ runner.temp }}/issuelens-team-memory-source + digest-mismatch: error + - name: Prepare and validate request id: preflight shell: bash diff --git a/.github/actions/issuelens/issuelens_action.py b/.github/actions/issuelens/issuelens_action.py index bb9b66b..93cabcf 100644 --- a/.github/actions/issuelens/issuelens_action.py +++ b/.github/actions/issuelens/issuelens_action.py @@ -64,12 +64,12 @@ def full_sha(value): return value -def github_request(path, payload=None, *, token=None): +def github_request(path, payload=None): return urllib.request.Request( "https://api.github.com" + path, data=None if payload is None else json.dumps(payload).encode("utf-8"), headers={ - "Authorization": "Bearer " + (os.environ["GH_TOKEN"] if token is None else token), + "Authorization": "Bearer " + os.environ["GH_TOKEN"], "Accept": "application/vnd.github+json", "Content-Type": "application/json", "X-GitHub-Api-Version": "2022-11-28", @@ -77,8 +77,8 @@ def github_request(path, payload=None, *, token=None): ) -def github_read(path, payload=None, *, token=None): - request = github_request(path, payload, token=token) +def github_read(path, payload=None): + request = github_request(path, payload) with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response: data = response.read(4 * 1024 * 1024 + 1) require(len(data) <= 4 * 1024 * 1024, "GitHub response exceeds the preflight limit") @@ -456,12 +456,16 @@ def prepare_coordinated_memory(repository, event): require(identifiers is not None, "Coordinated push requires source identifiers") metadata = verify_dispatch_source(repository, project, identifiers) snapshot = read_source_event() - require(snapshot["metadata"] == metadata, "Source artifact metadata does not match the verified run") + require(isinstance(snapshot["metadata"], dict) + and all(type(snapshot["metadata"].get(name)) is int + for name in ("repository_id", "run_id", "run_attempt")) + and snapshot["metadata"] == metadata, "Source artifact metadata does not match the verified run") push = snapshot["event"] require(isinstance(push, dict) and set(push) == {"repository", "ref", "before", "after", "created", "deleted", "forced", "commits", "head_commit"} and push["repository"] == {"id": project["id"], "full_name": repository} + and type(push["repository"]["id"]) is int and isinstance(push["commits"], list) and all(isinstance(item, dict) and set(item) == {"id"} for item in push["commits"]) and push["head_commit"] == {"id": metadata["workflow_sha"]}, @@ -469,35 +473,7 @@ def prepare_coordinated_memory(repository, event): return prepare_push_memory(repository, project, push, {**metadata, **coordinator_metadata(repository)}) -def dispatch_target(): - values = tuple(os.environ.get(name, "") for name in - ("COORDINATOR_REPOSITORY", "COORDINATOR_WORKFLOW", "COORDINATOR_REF")) - if not any(values): - return None - require(all(values), "Supply all three coordinator target inputs or omit all of them") - repository, workflow, reference = values - require(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}", - repository) and ".." not in repository, "Invalid coordinator-repository") - require(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\.ya?ml", workflow) - and ".." not in workflow, "coordinator-workflow must be a YAML workflow basename") - require(len(reference) <= 255 - and all(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", part) - and not part.endswith((".", ".lock")) for part in reference.split("/")) - and ".." not in reference and not reference.startswith("refs/"), - "coordinator-ref must be a simple branch name, not a full ref or revision expression") - return values - - -def dispatch_source_repository(target): - repository = os.environ["GITHUB_REPOSITORY"] - require(os.environ["GITHUB_EVENT_NAME"] == "push", "Only pushes may use this dispatcher") - require(target is not None or repository.lower() == COORDINATOR_REPOSITORY.lower(), - "Only IssueLens pushes may use the default dispatcher") - require(os.environ["GH_TOKEN"].strip(), "source-token must be non-empty") - return repository - - -def dispatch_snapshot(repository, event): +def team_memory_source_snapshot(repository, event): require(type(event["repository"]["id"]) is int and event["repository"]["full_name"].lower() == repository.lower(), "Event repository mismatch") project = validate_workflow(repository, event) @@ -517,11 +493,12 @@ def dispatch_snapshot(repository, event): } -def prepare_dispatch(): - target = dispatch_target() - repository = dispatch_source_repository(target) +def prepare_source(): + repository = os.environ["GITHUB_REPOSITORY"] + require(os.environ["GITHUB_EVENT_NAME"] == "push", "Only pushes may prepare a team-memory source") + require(os.environ["GH_TOKEN"].strip(), "Source read token must be non-empty") event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8")) - snapshot = dispatch_snapshot(repository, event) + snapshot = team_memory_source_snapshot(repository, event) content = json.dumps(snapshot, separators=(",", ":")).encode("utf-8") require(len(content) <= MAX_SOURCE_BYTES, "Source event exceeds 64 KiB") path = source_event_path() @@ -532,58 +509,8 @@ def prepare_dispatch(): print("Prepared identity-only team-memory source event") -def validate_dispatch_target(target, token): - repository, workflow, reference = target - project = github_read(f"/repos/{repository}", token=token) - require(isinstance(project.get("full_name"), str) - and project["full_name"].lower() == repository.lower() - and type(project.get("id")) is int and project["id"] > 0, - "Coordinator repository identity mismatch") - selected = github_read(f"/repos/{repository}/actions/workflows/{workflow}", token=token) - require(type(selected.get("id")) is int and selected["id"] > 0 - and selected.get("path") == ".github/workflows/" + workflow and selected.get("state") == "active", - "Coordinator workflow identity mismatch or workflow is inactive") - branch = github_read(f"/repos/{repository}/branches/{urllib.parse.quote(reference, safe='')}", token=token) - require(branch.get("name") == reference and isinstance(branch.get("commit"), dict), - "Coordinator branch identity mismatch") - full_sha(branch["commit"].get("sha")) - - -def dispatch(): - target = dispatch_target() - repository = dispatch_source_repository(target) - dispatch_token = os.environ.get("DISPATCH_TOKEN", os.environ["GH_TOKEN"]) - require(dispatch_token.strip(), "dispatch-token must be non-empty") - snapshot = read_source_event() - require(isinstance(snapshot["metadata"], dict) - and all(type(snapshot["metadata"].get(name)) is int - for name in ("repository_id", "run_id", "run_attempt")), - "Invalid source dispatch metadata types") - require(snapshot == dispatch_snapshot(repository, snapshot["event"]), - "Source dispatch metadata changed") - metadata = snapshot["metadata"] - artifact_id = positive(os.environ["SOURCE_ARTIFACT_ID"]) - inputs = { - "source_run_id": str(metadata["run_id"]), "source_run_attempt": str(metadata["run_attempt"]), - "source_artifact_id": str(artifact_id), - } - if target is None: - target = (repository, "team-memory-coordinator.yml", metadata["base_ref"]) - else: - validate_dispatch_target(target, dispatch_token) - inputs["source_repository"] = repository - coordinator, workflow, reference = target - request = github_request( - f"/repos/{coordinator}/actions/workflows/{workflow}/dispatches", - {"ref": reference, "inputs": inputs}, - token=dispatch_token, - ) - with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response: - require(response.status in {200, 204}, "Coordinator dispatch was not acknowledged; do not retry blindly") - print("Coordinator dispatch accepted; maintenance completion is reported by the coordinator run") - - -def validate_dispatch(): +def validate_source(): + require(os.environ["REQUEST_TYPE"] == "team-memory", "Only team-memory requests may download source artifacts") repository = os.environ["GITHUB_REPOSITORY"] event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8")) identifiers = source_identifiers() @@ -929,12 +856,10 @@ def run(command): preflight() elif command == "submit": submit() - elif command == "prepare-dispatch": - prepare_dispatch() - elif command == "dispatch": - dispatch() - elif command == "validate-dispatch": - validate_dispatch() + elif command == "prepare-source": + prepare_source() + elif command == "validate-source": + validate_source() else: raise ValueError("Unsupported action command") except ValueError as error: @@ -943,8 +868,6 @@ def run(command): message = ( "Agent submission failed or its outcome is unknown; inspect the target before retrying" if command == "submit" else - "Coordinator dispatch failed or its outcome is unknown; inspect coordinator runs before retrying" - if command == "dispatch" else "IssueLens preflight failed; no agent request was sent" ) raise SystemExit(f"::error::{message}") from None @@ -952,5 +875,5 @@ def run(command): if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=("preflight", "submit", "prepare-dispatch", "dispatch", "validate-dispatch")) + parser.add_argument("command", choices=("preflight", "submit", "prepare-source", "validate-source")) run(parser.parse_args().command) diff --git a/.github/actions/queue-team-memory/README.md b/.github/actions/queue-team-memory/README.md index 35b420a..3f72556 100644 --- a/.github/actions/queue-team-memory/README.md +++ b/.github/actions/queue-team-memory/README.md @@ -1,166 +1,106 @@ -# Queue Team Memory Action - -A composite action that validates a trusted default-branch push, uploads its -bounded identity-only source artifact, and sends one dispatch to the central -team-memory coordinator. It does not log in to Azure or invoke IssueLens. - -With all coordinator target inputs omitted, the current pilot accepts -only `microsoft/IssueLens` pushes from -`.github/workflows/team-memory-post-merge.yml` and dispatches -`.github/workflows/team-memory-coordinator.yml` in that same repository. -Supplying all three target inputs enables generic dispatch to an independently -validated coordinator repository, workflow, and branch. This does not enable workflows, -create credentials, or extend repository access. The receiver must own its -trusted source/workflow allowlist, artifact provenance checks, privacy and wiki -scope, queue, and final maintenance validation. - -## Usage - -The [IssueLens push workflow](../../workflows/team-memory-post-merge.yml) loads -this action and its sibling `issuelens` helper from `github.workflow_sha` with -credentials not persisted, then calls `./.github/actions/queue-team-memory`. -The source workflow still owns its push trigger, opt-in gate, permissions, and -timeout. - -A pinned remote action reference uses the same interface without a caller -checkout. Replace `FULL_COMMIT_SHA` with the reviewed 40-character commit SHA; -this placeholder is not a published version. This example applies only to the -IssueLens pilot's trusted source workflow: +# Standalone Workflow Dispatch Action + +A generic, self-contained transport that validates a target repository, +workflow, and branch, then sends one bounded GitHub `workflow_dispatch` with +caller-supplied inputs. Its name/path is retained for existing team-memory +callers, but it does not prepare source events, upload/download artifacts, +discover PRs, invoke IssueLens, or interpret wiki policy or job authorization. +Its only script, `dispatch.py`, lives in this action directory; there are no +sibling action imports, checkout dependencies, or external Python packages. + +## Inputs + +| Input | Purpose / default | +| --- | --- | +| `dispatch-token` | Target Contents read and Actions write access; defaults to `${{ github.token }}`. An explicitly empty token fails without fallback. | +| `coordinator-repository` | Target `owner/repository`; defaults to `${{ github.repository }}`. | +| `coordinator-workflow` | Required YAML workflow basename, such as `team-memory-coordinator.yml`, not a filesystem path, workflow ID, URL, or display name. | +| `coordinator-ref` | Target branch; defaults to `${{ github.ref_name }}`. Set it explicitly when the central branch differs from the caller's branch. | +| `workflow-inputs` | JSON object with at most ten string-valued inputs; defaults to `{}`. No source or job fields are synthesized. | + +Target values use a simple ASCII schema: owner 1-39 alphanumeric/hyphen +characters, starting and ending alphanumeric; repository 1-100 +alphanumeric/dot/underscore/hyphen characters, starting alphanumeric; workflow +basename starting alphanumeric with at most 100 characters before `.yml` or +`.yaml`; branch up to 255 characters with slash-separated components starting +alphanumeric and containing only alphanumeric/dot/underscore/hyphen. +`..`, empty branch components, components ending in `.` or `.lock`, and +`refs/`-prefixed branch names are rejected. Inputs use names starting with a +letter or underscore followed by up to 99 alphanumeric/underscore/hyphen +characters. Duplicate JSON keys, nonstring values, invalid JSON, and raw input +or encoded dispatch payloads over 64 KiB fail before network access. Do not +include credentials or sensitive bodies in dispatch inputs. + +The token must be caller-provided for cross-repository dispatch. A source +repository's `GITHUB_TOKEN` does not expand repository access when permissions +change. The action does not create credentials, install an App, change +permissions, or grant the receiving job authorization. + +## Team-Memory Usage + +The source workflow owns request-specific evidence preparation and artifact +upload **before** this action. In IssueLens, +[team-memory-post-merge.yml](../../workflows/team-memory-post-merge.yml) loads +trusted code at `github.workflow_sha` with credentials not persisted, calls the +request-owned `issuelens_action.py prepare-source` helper with a source read +token, and uploads its sanitized identity-only push artifact with a pinned +uploader. The artifact retains original `before`, `after`, and all commit IDs; +the source run API alone cannot recover the original push range. The +[invocation action](../issuelens/README.md#issuelens-coordinator-pilot) owns the +artifact schema and the receiving validation/download contract. + +A pinned remote dispatch action needs no caller checkout. Replace +`FULL_COMMIT_SHA` with a reviewed full commit SHA; this is not a published version. +After preparation/upload, the IssueLens pilot supplies its unchanged three-ID +payload to its same-repository coordinator: ```yaml -- name: Queue team-memory update +- name: Queue team-memory request uses: microsoft/IssueLens/.github/actions/queue-team-memory@FULL_COMMIT_SHA with: - source-token: ${{ github.token }} - dispatch-token: ${{ steps.dispatch-token.outputs.token }} + coordinator-workflow: team-memory-coordinator.yml + workflow-inputs: '{"source_run_id":"${{ github.run_id }}", "source_run_attempt":"${{ github.run_attempt }}", "source_artifact_id":"${{ steps.artifact.outputs.artifact-id }}"}' ``` -Both inputs default to `${{ github.token }}`, so the same-repository pilot -needs no token-minting step or new secret. If an explicit `dispatch-token` is -used, the caller must provide it, for example through a preceding GitHub App -token-minting step named `dispatch-token`. A separate dispatch App must not use -the hosted IssueLens App's private key. The action does not create credentials. - -| Input | Purpose | -| --- | --- | -| `source-token` | Source repository read access for provenance validation, including the validation immediately before dispatch. | -| `dispatch-token` | Coordinator metadata read access and Actions write access for the single dispatch POST. An explicitly empty token fails instead of falling back. | -| `coordinator-repository` | Coordinator `owner/repository`. Defaults to empty; supply all three target inputs together or omit all of them. | -| `coordinator-workflow` | YAML workflow basename, such as `team-memory-coordinator.yml`, not a path, URL, workflow ID, or display name. Defaults to empty. | -| `coordinator-ref` | Coordinator branch, independent of the source default branch. Defaults to empty. | - -The IssueLens source job retains `contents: read` and `actions: write` -permissions. A source repository's `GITHUB_TOKEN` does not expand repository access -when its permissions change; cross-repository callers must provide a -token authorized for the central repository. The coordinator will separately -need Actions read access to allowed source repositories. -For generic target checks, a fine-grained/App dispatch token needs Contents -read access (branch lookup) as well as Actions write access (workflow lookup -and dispatch) on the coordinator repository. The action does not install an App -or modify those permissions. - -### Generic / Cross-Repository Dispatch - -This example targets Java Pack's coordinator on `main`; the source workflow -must still run from its own current default branch, which may be `develop`. -It belongs in `.github/workflows/team-memory-post-merge.yml`. The example is -an interface illustration, not workflow enablement or a credential setup step: +For a source using `develop` and a central coordinator using `main`, set the +target ref independently and supply the receiving coordinator's own payload: ```yaml -- name: Queue team-memory update +- name: Queue team-memory request uses: microsoft/IssueLens/.github/actions/queue-team-memory@FULL_COMMIT_SHA with: - source-token: ${{ github.token }} dispatch-token: ${{ steps.dispatch-token.outputs.token }} coordinator-repository: microsoft/vscode-java-pack coordinator-workflow: team-memory-coordinator.yml coordinator-ref: main + workflow-inputs: '{"source_repository":"${{ github.repository }}", "source_run_id":"${{ github.run_id }}", "source_run_attempt":"${{ github.run_attempt }}", "source_artifact_id":"${{ steps.artifact.outputs.artifact-id }}"}' ``` -Target values use a deliberately simple ASCII schema: an owner (1-39 -alphanumeric/hyphen characters, starting and ending alphanumeric) and repository -(1-100 alphanumeric/dot/underscore/hyphen characters, starting alphanumeric); -a workflow basename starting alphanumeric with at most 100 characters before -`.yml` or `.yaml`; and a branch up to 255 characters whose slash-separated -components start alphanumeric and contain only alphanumeric/dot/underscore/hyphen. -`..`, empty branch components, components ending in `.` or `.lock`, and -`refs/`-prefixed branch names are rejected. Paths, URLs, query strings, and -revision expressions are not accepted. - -Target syntax is checked before artifact preparation and again before dispatch. -Immediately before the POST, authenticated reads with `dispatch-token` verify -the target repository identity, exact active workflow path, and branch identity -with a full nonzero commit SHA. Source validation independently uses -`source-token`; neither credential is substituted for the other. The branch -need not be the source branch or the target default branch; the receiver must -enforce its own allowed coordinator ref. - -Generic dispatch sends exactly four string inputs: `source_repository`, -`source_run_id`, `source_run_attempt`, and `source_artifact_id`. The source -repository comes only from `GITHUB_REPOSITORY`, verified against the event and -authenticated canonical repository identity; there is no caller-selectable -source repository input. The three IDs come from the original source run, -attempt, and upload output. An explicit target, including a same-repository -target, uses this four-input contract. Omitting all target inputs retains the -IssueLens pilot's existing three-ID payload and source default-branch ref; -its coordinator and the separate `issuelens` invocation action are unchanged. - -### Source Artifact Contract - -Both modes upload `issuelens-team-memory-source-${GITHUB_RUN_ATTEMPT}`, retained -for seven days, from -`${RUNNER_TEMP}/issuelens-team-memory-source/source-event.json`. The JSON is -bounded to 64 KiB and exactly two top-level keys, `metadata` and `event`: - -| Object | Exact fields | -| --- | --- | -| `metadata` | `repository`, `repository_id`, `base_ref`, `event_name`, `event_action`, `actor_login`, `triggering_actor`, `workflow_ref`, `workflow_sha`, `run_id`, `run_attempt` | -| `event` | `repository: {id, full_name}`, `ref`, `before`, `after`, `created`, `deleted`, `forced`, `commits: [{id}, ...]`, `head_commit: {id}` | - -Repository/run/attempt IDs are integers; the repository is the canonical source -name. `event_name` and `event_action` are `push`. `base_ref` is the source -default branch, `ref` is `refs/heads/` plus that branch, and `workflow_ref` -identifies that source's `.github/workflows/team-memory-post-merge.yml` at the -same branch. Actors are the workflow's `GITHUB_ACTOR` and -`GITHUB_TRIGGERING_ACTOR`. `workflow_sha`, `GITHUB_SHA`, `after`, and -`head_commit.id` must be the same full nonzero SHA. `before` must be a distinct -full nonzero SHA. The three push flags are exactly `false`. All 1-1000 supplied -commit IDs are retained in order, must be unique full nonzero SHAs, include -`after`, and exclude `before`. Empty/oversized inventories are rejected. -No target, token, body, message, or code fields are added. - -The dispatcher preserves the complete supplied push inventory; the receiver -must authenticate the source run/attempt and workflow path/identity, check the -artifact's immutable ID, exact name, origin, SHA-256 digest, size, retention and -expiry before downloading, and reject digest mismatches. It must then revalidate -the exact snapshot and the authoritative complete fast-forward range before -discovery/invocation. A nonempty but truncated event inventory is not proof of -completeness. Upload or dispatch acceptance alone is not trusted source evidence -or write authorization. - -## Execution and Outcomes - -The action runs preparation, pinned artifact upload, and one bounded dispatch -in order. Failure stops the sequence; there is no retry or agent invocation. -Every GitHub HTTP operation is bounded, rejects redirects, and uses a 30-second -transport timeout. Target authentication failures stop before the dispatch POST; -an artifact may already exist at that point. -The artifact contains only source identities, not source code, commit messages, -issue/PR bodies, agent responses, or credentials, and expires after seven days. -The shared standard-library helper is resolved relative to `github.action_path` -from the sibling `issuelens` directory in the same pinned action bundle, not -from the caller's checkout. - -The `source-artifact-id` output identifies the uploaded artifact for diagnostics. -An artifact may have been uploaded even when dispatch fails. Its presence proves -neither dispatch acceptance nor maintenance completion. A transport error may -leave dispatch outcome unknown; inspect coordinator runs before retrying. - -The composite action executes in the caller repository. The actual queue lives -in the selected coordinator workflow, not in the action. The unchanged -[IssueLens coordinator](../../workflows/team-memory-coordinator.yml) allows -one active run and at most 100 pending runs in -`issuelens-team-memory-wiki-microsoft-IssueLens`, with `queue: max` and no -in-progress cancellation. Only that coordinator invokes IssueLens and validates -the final maintenance receipt. This does not serialize chat or external direct -invocations, guarantee delivery, or prove a timed-out hosted invocation stopped. +The second example is a later consumer migration contract, not enablement or +a change to Java Pack. The caller authenticates/prepares its source separately +from this target-only action. Receivers own source/workflow allowlists, +run/attempt/artifact provenance and digest checks, privacy/wiki scope, the +central queue, and final maintenance validation. Arbitrary dispatch input +claims are not trusted provenance or write authorization. + +## Transport and Outcomes + +Before a write, authenticated GETs independently verify the target repository +identity, exact active workflow path, and selected branch with a full nonzero +SHA. All HTTP operations reject redirects, use a 30-second timeout, and bound +metadata responses to 64 KiB. One POST is attempted, with no automatic retry. +Errors are static/sanitized and never disclose response bodies, input values, +or credentials. + +Acknowledgement means only that GitHub accepted the dispatch, not that a +coordinator ran, was admitted to a queue, invoked the agent, or completed a job. +An ambiguous POST failure reports an unknown outcome; inspect target runs +before retrying. There is no success-shaped job receipt or artifact output. +The action executes in the caller repository; the actual concurrency queue +remains in the selected central workflow, not in the action. + +**Compatibility change:** unlike the prior bundled dispatcher, this action +requires `coordinator-workflow` and caller-owned `workflow-inputs`, no longer +accepts `source-token`, and does not prepare/upload a source artifact or infer +`source_repository`/run IDs. Consumers pinned to the older immutable revision +continue unchanged until deliberately migrated. diff --git a/.github/actions/queue-team-memory/action.yml b/.github/actions/queue-team-memory/action.yml index a962d31..a92d297 100644 --- a/.github/actions/queue-team-memory/action.yml +++ b/.github/actions/queue-team-memory/action.yml @@ -1,64 +1,37 @@ -name: Queue IssueLens team memory -description: Preserve a validated default-branch push and dispatch its centrally queued team-memory workflow. +name: Dispatch coordinator workflow +description: Validate a target workflow and send one bounded dispatch with caller-supplied inputs. inputs: - source-token: - description: Source repository read token for push provenance validation. - required: false - default: ${{ github.token }} dispatch-token: - description: Token for coordinator metadata reads and the single Actions dispatch POST. + description: Target repository token with Contents read and Actions write access. required: false default: ${{ github.token }} coordinator-repository: - description: Coordinator owner/repository; supply all three coordinator inputs to enable generic dispatch. + description: Target owner/repository; defaults to the calling repository. required: false - default: "" + default: ${{ github.repository }} coordinator-workflow: - description: Coordinator workflow basename (for example team-memory-coordinator.yml); omit all target inputs for the IssueLens pilot. - required: false - default: "" + description: Target YAML workflow basename, not a path or display name. + required: true coordinator-ref: - description: Coordinator branch, independent of the source default branch; required with the other target inputs. + description: Target branch, independent of the source branch; defaults to the calling ref name. required: false - default: "" - -outputs: - source-artifact-id: - description: Uploaded source artifact ID; its presence does not imply accepted dispatch or completed maintenance. - value: ${{ steps.artifact.outputs.artifact-id }} + default: ${{ github.ref_name }} + workflow-inputs: + description: JSON object containing at most ten string-valued workflow inputs; no job-specific fields are inferred. + required: false + default: '{}' runs: using: composite steps: - - name: Prepare identity-only source event - id: source - shell: bash - env: - GITHUB_ACTION_PATH: ${{ github.action_path }} - GH_TOKEN: ${{ inputs.source-token }} - COORDINATOR_REPOSITORY: ${{ inputs.coordinator-repository }} - COORDINATOR_WORKFLOW: ${{ inputs.coordinator-workflow }} - COORDINATOR_REF: ${{ inputs.coordinator-ref }} - run: python3 -I "$GITHUB_ACTION_PATH/../issuelens/issuelens_action.py" prepare-dispatch - - - name: Preserve source event for the coordinator - id: artifact - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: issuelens-team-memory-source-${{ github.run_attempt }} - path: ${{ steps.source.outputs.source-event-path }} - if-no-files-found: error - retention-days: 7 - - - name: Queue team-memory request + - name: Dispatch coordinator workflow shell: bash env: GITHUB_ACTION_PATH: ${{ github.action_path }} - GH_TOKEN: ${{ inputs.source-token }} DISPATCH_TOKEN: ${{ inputs.dispatch-token }} - SOURCE_ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }} COORDINATOR_REPOSITORY: ${{ inputs.coordinator-repository }} COORDINATOR_WORKFLOW: ${{ inputs.coordinator-workflow }} COORDINATOR_REF: ${{ inputs.coordinator-ref }} - run: python3 -I "$GITHUB_ACTION_PATH/../issuelens/issuelens_action.py" dispatch + WORKFLOW_INPUTS: ${{ inputs.workflow-inputs }} + run: python3 -I "$GITHUB_ACTION_PATH/dispatch.py" diff --git a/.github/actions/queue-team-memory/dispatch.py b/.github/actions/queue-team-memory/dispatch.py new file mode 100644 index 0000000..f8c95c0 --- /dev/null +++ b/.github/actions/queue-team-memory/dispatch.py @@ -0,0 +1,133 @@ +"""Standalone, single-attempt GitHub workflow dispatch transport.""" + +import json +import os +import re +import urllib.parse +import urllib.request + + +MAX_BYTES = 64 * 1024 + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, "workflow-inputs contains duplicate keys") + result[key] = value + return result + + +def configuration(): + repository = os.environ.get("COORDINATOR_REPOSITORY", "") + workflow = os.environ.get("COORDINATOR_WORKFLOW", "") + reference = os.environ.get("COORDINATOR_REF", "") + token = os.environ.get("DISPATCH_TOKEN", "") + require(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}", + repository) and ".." not in repository, "Invalid coordinator-repository") + require(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\.ya?ml", workflow) + and ".." not in workflow, "coordinator-workflow must be a YAML workflow basename") + require(len(reference) <= 255 + and all(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", part) + and not part.endswith((".", ".lock")) for part in reference.split("/")) + and ".." not in reference and not reference.startswith("refs/"), + "coordinator-ref must be a simple branch name") + require(token.strip() and not any(character.isspace() for character in token), + "dispatch-token must be non-empty and contain no whitespace") + content = os.environ.get("WORKFLOW_INPUTS", "{}") + require(len(content.encode("utf-8")) <= MAX_BYTES, "workflow-inputs exceeds 64 KiB") + try: + inputs = json.loads(content, object_pairs_hook=unique_object) + except json.JSONDecodeError: + raise ValueError("workflow-inputs must be a JSON object") from None + require(isinstance(inputs, dict) and len(inputs) <= 10, "workflow-inputs must contain at most ten inputs") + require(all(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]{0,99}", key) and isinstance(value, str) + for key, value in inputs.items()), "workflow-inputs requires valid names and string values") + require(token not in content and token not in json.dumps(inputs), + "workflow-inputs must not contain the dispatch credential") + payload = json.dumps({"ref": reference, "inputs": inputs}).encode("utf-8") + require(len(payload) <= MAX_BYTES, "Dispatch payload exceeds 64 KiB") + return repository, workflow, reference, token, payload + + +def request(path, token, payload=None): + return urllib.request.Request( + "https://api.github.com" + path, data=payload, + headers={ + "Authorization": "Bearer " + token, + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + + +def read(opener, path, token): + try: + with opener.open(request(path, token), timeout=30) as response: + status = response.status + content = response.read(MAX_BYTES + 1) + except Exception: + raise ValueError("Target metadata validation failed; no workflow dispatch was sent") from None + require(status == 200, "Target metadata was not acknowledged") + require(len(content) <= MAX_BYTES, "Target metadata exceeds 64 KiB") + # Invalid remote content must never become a user-facing diagnostic. + try: + result = json.loads(content) + except (json.JSONDecodeError, UnicodeDecodeError): + raise ValueError("Invalid target metadata") from None + require(isinstance(result, dict), "Invalid target metadata") + return result + + +def dispatch(): + repository, workflow, reference, token, payload = configuration() + opener = urllib.request.build_opener(NoRedirect()) + project = read(opener, f"/repos/{repository}", token) + require(isinstance(project.get("full_name"), str) + and project["full_name"].lower() == repository.lower() + and type(project.get("id")) is int and project["id"] > 0, + "Target repository identity mismatch") + selected = read(opener, f"/repos/{repository}/actions/workflows/{workflow}", token) + require(type(selected.get("id")) is int and selected["id"] > 0 + and selected.get("path") == ".github/workflows/" + workflow and selected.get("state") == "active", + "Target workflow identity mismatch or workflow is inactive") + branch = read(opener, f"/repos/{repository}/branches/{urllib.parse.quote(reference, safe='')}", token) + require(branch.get("name") == reference and isinstance(branch.get("commit"), dict) + and isinstance(branch["commit"].get("sha"), str) + and re.fullmatch(r"[0-9a-f]{40}", branch["commit"]["sha"]) + and branch["commit"]["sha"] != "0" * 40, "Target branch identity mismatch") + try: + with opener.open(request(f"/repos/{repository}/actions/workflows/{workflow}/dispatches", token, payload), + timeout=30) as response: + status = response.status + except Exception: + raise ValueError("Dispatch failed or its outcome is unknown; inspect target runs before retrying") from None + require(status in {200, 204}, "Dispatch was not acknowledged; inspect target runs before retrying") + try: + print("Workflow dispatch accepted; target execution and job completion are not confirmed") + except (OSError, ValueError): + raise ValueError("Dispatch acknowledged but output unavailable; target job completion is unconfirmed") from None + + +def run(): + try: + dispatch() + except ValueError as error: + raise SystemExit(f"::error::{error}") from None + except Exception: + raise SystemExit("::error::Target validation failed; no workflow dispatch was sent") from None + + +if __name__ == "__main__": + run() diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 30ae4ee..c1daf08 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -318,29 +318,29 @@ trusted event metadata. Per-issue concurrency allows different issues to run independently while coalescing bursts for the same issue. Team-memory postmerge orchestration in this repository uses two opt-in workflows: -`.github/workflows/team-memory-post-merge.yml` calls the reusable composite -`.github/actions/queue-team-memory`, which validates default-branch pushes, -uploads a bounded identity-only source artifact retained for seven days, and -dispatches `.github/workflows/team-memory-coordinator.yml` with source run, -attempt, and immutable artifact IDs. The dispatcher needs repository-scoped -Actions write access, but no Azure credentials or agent invocation. The queue -action separates `source-token` reads from the `dispatch-token` POST, defaulting -both to `github.token` for this same-repository pilot. It resolves the existing -`issuelens` Python helper from the same pinned action bundle, not the caller's -checkout. Explicit `coordinator-repository`, `coordinator-workflow` (YAML basename), -and `coordinator-ref` (branch) inputs activate generic dispatch, supplied all -together. Generic mode independently validates the target with `dispatch-token` -and adds authenticated `source_repository` to its dispatch inputs, never to the -unchanged identity-only artifact. The coordinator branch is independent of the -source default branch. Omitting all target inputs preserves the IssueLens pilot's -three-ID payload and defaults. Receiving coordinators own their source/workflow -allowlists, artifact provenance, privacy/wiki scope, and final outcome validation; -target inputs and tokens grant no new access or authority. -Hosting the action centrally does not relocate its execution; the -actual concurrency queue remains in the dispatched coordinator workflow. The -coordinator verifies the trusted source workflow/run and artifact provenance -before download; the pinned downloader rejects digest mismatches. The shared -action revalidates that evidence before source discovery and OIDC login. +`.github/workflows/team-memory-post-merge.yml` uses the request-owned +`issuelens_action.py prepare-source` helper to validate the default-branch push, +then a pinned uploader preserves its bounded identity-only artifact for seven +days. Original before/after and all commit IDs remain explicit; the source run +API alone does not establish the original before range. It then calls the +standalone generic `.github/actions/queue-team-memory` action to dispatch +`.github/workflows/team-memory-coordinator.yml` with its unchanged three-ID +payload in caller-supplied `workflow-inputs` JSON. The dispatcher owns only +validated target dispatch: its own `dispatch.py`, no sibling action imports, +source preparation, artifacts, agent calls, wiki policy, or job authorization. +Its `coordinator-repository` defaults to the caller repository, +`coordinator-workflow` requires a YAML basename, and `coordinator-ref` defaults +to the calling branch, independent of any source default branch. Its target +token requires Contents read and Actions write; source preparation independently +uses a source read token. Target inputs and credentials grant no new access. +The central coordinator owns the actual concurrency queue and only calls +`.github/actions/issuelens` after trusted sparse checkout. The invocation action +owns source/workflow allowlists, verification before pinned digest-checked +download, revalidation/discovery before OIDC login, policy/privacy, and final +outcomes. Its IssueLens coordinated adapter stays pilot-scoped; direct external +action callers retain their request behavior. Dispatch no longer prepares or +uploads artifacts or infers source fields. Java Pack's older immutable pin stays +unchanged and needs a separately authorized consumer migration. Explicit single-PR manual requests use the coordinator instead of source IDs. The shared composite action in `.github/actions/issuelens` owns preflight, pinned Azure OIDC login, and submission through a standalone diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 192f86a..b5d3fd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - name: Install application and test dependencies run: python -m pip install -r requirements.txt -r requirements-ci.txt - name: Validate Python syntax - run: python -m compileall -q *.py .github/actions/issuelens github_app_mcp/src github_app_mcp/scripts tests github_app_mcp/tests + run: python -m compileall -q *.py .github/actions/issuelens .github/actions/queue-team-memory github_app_mcp/src github_app_mcp/scripts tests github_app_mcp/tests - name: Run application tests run: python -m unittest discover -s tests -p 'test_*.py' -v diff --git a/.github/workflows/team-memory-coordinator.yml b/.github/workflows/team-memory-coordinator.yml index f5c827f..25f1c67 100644 --- a/.github/workflows/team-memory-coordinator.yml +++ b/.github/workflows/team-memory-coordinator.yml @@ -50,28 +50,6 @@ jobs: sparse-checkout: /.github/actions/issuelens/ sparse-checkout-cone-mode: false - - name: Verify queued source - id: source - shell: bash - env: - GH_TOKEN: ${{ github.token }} - SOURCE_RUN_ID: ${{ inputs.source_run_id }} - SOURCE_RUN_ATTEMPT: ${{ inputs.source_run_attempt }} - SOURCE_ARTIFACT_ID: ${{ inputs.source_artifact_id }} - DISPATCH_PR: ${{ inputs.pull_request_number }} - run: python3 -I .github/actions/issuelens/issuelens_action.py validate-dispatch - - - name: Download verified source event - if: steps.source.outputs.automatic == 'true' - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - artifact-ids: ${{ steps.source.outputs.source-artifact-id }} - run-id: ${{ steps.source.outputs.source-run-id }} - repository: ${{ github.repository }} - github-token: ${{ github.token }} - path: ${{ runner.temp }}/issuelens-team-memory-source - digest-mismatch: error - - name: Maintain team memory uses: ./.github/actions/issuelens with: diff --git a/.github/workflows/team-memory-post-merge.yml b/.github/workflows/team-memory-post-merge.yml index 602c455..bcf1082 100644 --- a/.github/workflows/team-memory-post-merge.yml +++ b/.github/workflows/team-memory-post-merge.yml @@ -29,5 +29,27 @@ jobs: /.github/actions/queue-team-memory/ sparse-checkout-cone-mode: false + - name: Prepare identity-only team-memory source + id: source + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: python3 -I .github/actions/issuelens/issuelens_action.py prepare-source + + - name: Preserve source event for the coordinator + id: artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: issuelens-team-memory-source-${{ github.run_attempt }} + path: ${{ steps.source.outputs.source-event-path }} + if-no-files-found: error + retention-days: 7 + - name: Queue team-memory request uses: ./.github/actions/queue-team-memory + with: + coordinator-workflow: team-memory-coordinator.yml + workflow-inputs: >- + {"source_run_id":"${{ github.run_id }}", + "source_run_attempt":"${{ github.run_attempt }}", + "source_artifact_id":"${{ steps.artifact.outputs.artifact-id }}"} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2c3720..69f46dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,6 +55,7 @@ separate explicit approval. | `github_app_mcp/` | GitHub App MCP server, packaging, and isolated tests | | `tests/` | Application, prompt-contract, workflow, and documentation tests | | `.github/actions/issuelens/` | Reusable GitHub Actions integration and request helpers | +| `.github/actions/queue-team-memory/` | Standalone generic coordinator workflow dispatch transport | | `.github/workflows/` | CI, deployment, and operational workflows | | `examples/` and `schemas/` | Target-repository configuration examples and schema | | `docs/` and `observability/` | Setup, operations, and reporting resources | @@ -114,7 +115,7 @@ python -m unittest discover -s tests -p 'test_issue_triage_workflow.py' -v The complete application checks are: ```bash -python -m compileall -q *.py .github/actions/issuelens github_app_mcp/src github_app_mcp/scripts tests github_app_mcp/tests +python -m compileall -q *.py .github/actions/issuelens .github/actions/queue-team-memory github_app_mcp/src github_app_mcp/scripts tests github_app_mcp/tests python -m unittest discover -s tests -p 'test_*.py' -v ``` @@ -123,7 +124,7 @@ Python files explicitly: ```powershell $rootModules = Get-ChildItem -File -Filter *.py | Select-Object -ExpandProperty Name -python -m compileall -q $rootModules .github\actions\issuelens github_app_mcp\src github_app_mcp\scripts tests github_app_mcp\tests +python -m compileall -q $rootModules .github\actions\issuelens .github\actions\queue-team-memory github_app_mcp\src github_app_mcp\scripts tests github_app_mcp\tests ``` ### Standalone MCP tests and package checks diff --git a/docs/guide.md b/docs/guide.md index accf6cc..a734f9b 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -179,8 +179,9 @@ contracts do not establish live hosted sub-agent dispatch or deployment. that land fork PRs, so the trusted base workflow has endpoint credentials without a `pull_request_target` policy exception. Set the `push.branches` filter to the source repository's default branch (`main` in this repository). - IssueLens's own push dispatcher loads the local `queue-team-memory` action - and its sibling `issuelens` helper; the coordinator loads `issuelens`. + IssueLens's own push workflow loads the standalone `queue-team-memory` + action and separately the request-owned `issuelens` preparation helper; + the coordinator loads and calls only `issuelens`. Both use `github.workflow_sha` with credentials not persisted. Neither path checks out or executes PR-head code. Protect workflow and action changes as privileged code. @@ -222,14 +223,20 @@ across separate pushes, so an ordinary one-PR merge still usually produces one r [push-triggered workflow](../.github/workflows/team-memory-post-merge.yml) no longer calls Foundry. It calls the reusable [queue-team-memory action](../.github/actions/queue-team-memory/README.md), which -encapsulates source validation, artifact upload, and one coordinator dispatch. -The wrapper separates source-read and dispatch-write token inputs, both defaulting -to `github.token` for this pilot. It validates the push, stores a bounded identity-only -source artifact for seven days, and dispatches the +is a standalone generic target dispatcher. The source workflow first calls the +request-owned `issuelens_action.py prepare-source` helper with its source read +token and a pinned artifact uploader. It validates the push and stores a bounded +identity-only source artifact for seven days, retaining original before/after +and all commit IDs. The run API alone cannot recover original before. +The separate dispatcher uses only a target token and caller-supplied JSON inputs +to dispatch the [coordinator workflow](../.github/workflows/team-memory-coordinator.yml) with only the source run, attempt, and immutable artifact IDs. No source code, commit messages, issue/PR bodies, agent output, or credentials are uploaded. -The coordinator checks the originating dispatcher, source repository/default +The coordinator keeps the queue but only invokes the shared `issuelens` action +after trusted sparse checkout. That invocation action verifies and downloads +the source artifact internally before Azure login. It checks the originating +dispatcher, source repository/default branch, run attempt, head SHA, and artifact provenance before downloading. Missing digests, digest mismatches, expired or oversized artifacts, and mismatched source identities fail before Azure login. The shared action revalidates the source @@ -256,16 +263,20 @@ Java tooling rollout is separate: those repositories share the `microsoft/vscode-java-pack` wiki and will need their own central queue and authenticated source adapter. The existing direct-action consumers continue working unchanged, but are not serialized by this pilot. -The shared queue action supports explicit `coordinator-repository`, -`coordinator-workflow` (YAML basename), and `coordinator-ref` (branch) inputs, -supplied together. It validates target identities using the dispatch token and -adds `source_repository` to generic dispatches without changing the source -artifact. The coordinator branch is independent of the source default branch. +The standalone dispatch action supports `coordinator-repository` (default +calling repository), required `coordinator-workflow` (YAML basename), +`coordinator-ref` (default calling ref name), and caller-owned `workflow-inputs` +JSON. It independently validates target identities using `dispatch-token` and +never prepares artifacts or synthesizes source identity fields. +The coordinator branch is independent of the source default branch. See the [queue action contract](../.github/actions/queue-team-memory/README.md) for the exact input and artifact schemas. Receivers still own source allowlists, artifact/range revalidation, privacy, wiki scope, and maintenance outcomes; this transport interface does not enable other workflows or provide credentials. -Omitting all target inputs preserves the IssueLens pilot unchanged. +The IssueLens source workflow explicitly supplies its unchanged three-ID payload. +This is a deliberate dispatch-action interface change: source preparation/upload +and payload construction are now caller-owned. Java Pack remains pinned to the +older revision and needs a separate migration; this refactor does not alter it. Only PRs merged into the current default branch are accepted. Manual **Run workflow** now uses the coordinator, with a positive `pull_request_number` diff --git a/github_app_mcp/README.md b/github_app_mcp/README.md index 8b8a56e..83bdae7 100644 --- a/github_app_mcp/README.md +++ b/github_app_mcp/README.md @@ -337,13 +337,22 @@ read-only skill never writes or delegates ordinary reads to the writer. Git provides knowledge, history, and conflict detection, not a durable job queue, reconciliation service, external scheduler, or guaranteed exactly-once delivery. The optional [post-merge dispatcher](../.github/workflows/team-memory-post-merge.yml) -uses the [queue-team-memory action](../.github/actions/queue-team-memory/README.md) -to validate the push, upload an identity-only source artifact, and dispatch the +uses request-owned code from the +[invocation action](../.github/actions/issuelens/README.md) to validate the push +and a pinned uploader to preserve its identity-only before/after/commit inventory, +then uses the [standalone dispatch action](../.github/actions/queue-team-memory/README.md) +only to validate the target and dispatch caller-supplied workflow inputs to the [queued coordinator](../.github/workflows/team-memory-coordinator.yml). The dispatcher does not authenticate to Azure or invoke the agent. Only the coordinator performs Azure OIDC login and agent submission through the [shared IssueLens action](../.github/actions/issuelens/README.md) with -`request-type: team-memory`, after verifying source run and artifact provenance. +`request-type: team-memory`. The central workflow owns the queue and only calls +the action (plus trusted local checkout); the invocation action internally +verifies source run/artifact provenance, performs pinned digest-checked download, +and revalidates the original complete push inventory before Azure login. +The generic dispatcher is self-contained and never imports invocation scripts, +prepares job evidence, or grants job authorization. The artifact's original +before range cannot be reconstructed from the run API alone. Automatic push requests and manual PR requests share the coordinator's queue. This pilot accepts only `microsoft/IssueLens` sources and its configured wiki; external direct-action consumers remain supported but are not queued by it. diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index 16ee168..29182ea 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -160,7 +160,7 @@ def test_package_build_and_install_are_isolated_from_source_imports(self): def test_validation_tools_and_commands_are_documented(self): contributing = (ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8") - syntax = "python -m compileall -q *.py .github/actions/issuelens github_app_mcp/src github_app_mcp/scripts tests github_app_mcp/tests" + syntax = "python -m compileall -q *.py .github/actions/issuelens .github/actions/queue-team-memory github_app_mcp/src github_app_mcp/scripts tests github_app_mcp/tests" self.assertIn(syntax, self.commands("application-tests")) self.assertIn(syntax, contributing) lint = self.commands("workflow-validation") diff --git a/tests/test_issue_triage_workflow.py b/tests/test_issue_triage_workflow.py index eeb7dcc..550ef33 100644 --- a/tests/test_issue_triage_workflow.py +++ b/tests/test_issue_triage_workflow.py @@ -29,7 +29,9 @@ def test_preflight_rejects_pr_and_bot_comments_before_login(self): self.assertIn("github.event.comment.user.type == 'User'", gate) self.assertIn("github.event.repository.default_branch", gate) metadata = yaml.load((action_tests.ACTION_DIR / "action.yml").read_text(encoding="utf-8"), Loader=yaml.BaseLoader) - preflight, login, submit = metadata["runs"]["steps"] + verify, download, preflight, login, submit = metadata["runs"]["steps"] + self.assertIn("inputs.request-type == 'team-memory'", verify["if"]) + self.assertEqual(download["if"], "steps.source.outputs.automatic == 'true'") self.assertEqual(preflight["id"], "preflight") for step in (login, submit): self.assertEqual(step["if"], "steps.preflight.outputs.eligible == 'true'") diff --git a/tests/test_team_memory_coordinator.py b/tests/test_team_memory_coordinator.py index 0048d28..2521685 100644 --- a/tests/test_team_memory_coordinator.py +++ b/tests/test_team_memory_coordinator.py @@ -2,8 +2,6 @@ import json import pathlib import unittest -import urllib.error -from unittest.mock import patch import test_team_memory_workflow as memory_tests @@ -104,7 +102,7 @@ def test_dispatcher_preserves_only_identities_and_never_invokes_foundry(self): self.event["commits"][0]["message"] = "UNTRUSTED_COMMIT_TEXT" self.event["head_commit"]["message"] = "UNTRUSTED_HEAD_TEXT" self.event["repository"]["description"] = "UNTRUSTED_REPOSITORY_TEXT" - self.execute("prepare-dispatch", self.responses(self.project)) + self.execute("prepare-source", self.responses(self.project)) path = pathlib.Path(self.action_outputs()["source-event-path"]) snapshot = json.loads(path.read_bytes()) self.assertEqual(snapshot, self.snapshot) @@ -114,67 +112,7 @@ def test_dispatcher_preserves_only_identities_and_never_invokes_foundry(self): self.assertEqual(self.opener.open.call_count, 1) self.token.assert_not_called() - def test_dispatch_is_one_post_to_the_fixed_default_branch_coordinator(self): - self.select_dispatcher() - self.write_source() - ack = Response(b"") - ack.status = 204 - self.execute("dispatch", self.responses(self.project) + [ack]) - request = self.opener.open.call_args.args[0] - self.assertEqual(request.full_url, - "https://api.github.com/repos/microsoft/IssueLens/actions/workflows/team-memory-coordinator.yml/dispatches") - self.assertEqual(request.get_method(), "POST") - self.assertEqual(json.loads(request.data), { - "ref": "main", "inputs": { - "source_run_id": "123456", "source_run_attempt": "2", "source_artifact_id": "456", - }, - }) - self.assertNotIn(b"input", request.data.replace(b'"inputs"', b'')) - self.assertNotIn(b"wiki", request.data) - self.assertNotIn(b"fake-repository-token", request.data) - self.token.assert_not_called() - self.assertIn("completion is reported by the coordinator", self.output.getvalue()) - - def test_dispatch_failure_never_retries_or_claims_maintenance_completion(self): - self.select_dispatcher() - self.write_source() - with self.assertRaisesRegex(SystemExit, "outcome is unknown"): - self.execute("dispatch", self.responses(self.project) + [OSError("PRIVATE TRANSPORT DETAIL")]) - self.assertEqual(self.opener.open.call_count, 2) - self.token.assert_not_called() - self.assertNotIn("completed", self.output.getvalue()) - self.assertFalse((self.directory / "output.txt").exists()) - - def test_dispatch_uses_separate_source_read_and_coordinator_write_tokens(self): - self.select_dispatcher() - self.environment["DISPATCH_TOKEN"] = "fake-dispatch-token" - path = self.write_source() - ack = Response(b"") - ack.status = 204 - self.execute("dispatch", self.responses(self.project) + [ack]) - read, write = [call.args[0] for call in self.opener.open.call_args_list] - self.assertEqual(read.get_method(), "GET") - self.assertEqual(read.get_header("Authorization"), "Bearer fake-repository-token") - self.assertEqual(write.get_method(), "POST") - self.assertEqual(write.get_header("Authorization"), "Bearer fake-dispatch-token") - for token in ("fake-repository-token", "fake-dispatch-token"): - self.assertNotIn(token, path.read_text()) - self.assertNotIn(token, self.output.getvalue()) - self.assertNotIn(token.encode(), write.data) - self.token.assert_not_called() - - def test_explicit_empty_dispatch_token_never_falls_back_to_source_token(self): - self.select_dispatcher() - self.write_source() - for value in ("", " \t"): - with self.subTest(value=value): - self.environment["DISPATCH_TOKEN"] = value - with self.assertRaisesRegex(SystemExit, "dispatch-token must be non-empty"): - self.execute("dispatch", []) - self.opener.open.assert_not_called() - self.token.assert_not_called() - - def test_dispatcher_rejects_other_repositories_workflows_and_unsafe_pushes(self): + def test_source_preparation_rejects_mismatched_repositories_workflows_and_unsafe_pushes(self): self.select_dispatcher() original_environment, original_event = self.environment.copy(), copy.deepcopy(self.event) for change in ("repository", "workflow", "branch", "workflow_sha", "forced", "truncated"): @@ -193,7 +131,7 @@ def test_dispatcher_rejects_other_repositories_workflows_and_unsafe_pushes(self) else: self.event["commits"] = [] with self.assertRaises(SystemExit): - self.execute("prepare-dispatch", self.responses(self.project)) + self.execute("prepare-source", self.responses(self.project)) self.assertFalse((self.directory / "output.txt").exists()) self.assertFalse((self.directory / "issuelens-team-memory-source").exists()) self.token.assert_not_called() @@ -202,13 +140,73 @@ def test_maximum_commit_inventory_fits_the_identity_artifact_budget(self): self.select_dispatcher() commits = [{"id": f"{index:040x}"} for index in range(1, action.MAX_PUSH_COMMITS)] self.event["commits"] = commits + [{"id": self.after}] - self.execute("prepare-dispatch", self.responses(self.project)) + self.execute("prepare-source", self.responses(self.project)) content = pathlib.Path(self.action_outputs()["source-event-path"]).read_bytes() self.assertLess(len(content), action.MAX_SOURCE_BYTES) self.assertEqual(len(json.loads(content)["event"]["commits"]), action.MAX_PUSH_COMMITS) + pathlib.Path(self.action_outputs()["source-event-path"]).unlink() + (self.directory / "output.txt").unlink() + self.event["commits"].append({"id": f"{action.MAX_PUSH_COMMITS:040x}"}) + with self.assertRaisesRegex(SystemExit, "inventory"): + self.execute("prepare-source", self.responses(self.project)) + self.assertFalse((self.directory / "output.txt").exists()) + + def test_request_owned_source_supports_develop_and_exact_unchanged_snapshot(self): + self.select_dispatcher() + repository = "example/gradle" + self.project.update(full_name=repository, default_branch="develop") + self.environment.update( + GITHUB_REPOSITORY=repository, GITHUB_REF="refs/heads/develop", + GITHUB_WORKFLOW_REF=repository + "/" + action.DISPATCH_WORKFLOW + "@refs/heads/develop", + ) + self.event.update(repository={"id": 100, "full_name": repository}, ref="refs/heads/develop") + self.snapshot["metadata"].update(repository=repository, base_ref="develop", + workflow_ref=self.environment["GITHUB_WORKFLOW_REF"]) + self.snapshot["event"].update(repository={"id": 100, "full_name": repository}, ref="refs/heads/develop") + self.execute("prepare-source", self.responses(self.project)) + path = pathlib.Path(self.action_outputs()["source-event-path"]) + self.assertEqual(json.loads(path.read_bytes()), self.snapshot) + self.assertNotIn("fake-repository-token", path.read_text()) + self.assertNotIn("coordinator", path.read_text()) + self.assertEqual([call.args[0].get_method() for call in self.opener.open.call_args_list], ["GET"]) + self.token.assert_not_called() + + def test_actual_prepared_artifact_round_trips_through_coordinator_validation_and_discovery(self): + coordinator_environment = self.environment.copy() + self.select_dispatcher() + self.execute("prepare-source", self.responses(self.project)) + actual = pathlib.Path(self.action_outputs()["source-event-path"]).read_bytes() + (self.directory / "output.txt").unlink() + self.environment = coordinator_environment + self.event = {"repository": self.project} + self.execute("validate-source", self.source_responses()) + (self.directory / "output.txt").unlink() + self.execute("preflight", self.source_responses() + self.responses(self.comparison, self.associations)) + metadata = self.prepared_envelope()["metadata"] + self.assertEqual(json.loads(actual), self.snapshot) + self.assertEqual(metadata["push_before"], self.before) + self.assertEqual(metadata["push_after"], self.after) + self.assertEqual(metadata["source_tip_sha"], self.tip) + self.assertNotEqual(metadata["workflow_sha"], self.environment["GITHUB_SHA"]) + self.assertEqual(metadata["pull_requests"][0]["merge_commit_sha"], self.merge_sha) + self.token.assert_not_called() + + def test_obsolete_dispatch_entrypoints_are_removed(self): + for command in ("dispatch", "prepare-dispatch", "validate-dispatch"): + with self.subTest(command=command), self.assertRaisesRegex(SystemExit, "Unsupported action command"): + self.execute(command, []) + self.opener.open.assert_not_called() + for name in ("dispatch", "prepare_dispatch", "validate_dispatch", "dispatch_target", "validate_dispatch_target"): + self.assertFalse(hasattr(action, name)) + + def test_source_download_validation_cannot_be_used_by_other_request_adapters(self): + self.environment["REQUEST_TYPE"] = "task" + with self.assertRaisesRegex(SystemExit, "Only team-memory"): + self.execute("validate-source", []) + self.opener.open.assert_not_called() def test_verifies_run_attempt_and_artifact_before_download(self): - self.execute("validate-dispatch", self.source_responses()) + self.execute("validate-source", self.source_responses()) self.assertEqual(self.action_outputs(), { "automatic": "true", "source-run-id": "123456", "source-artifact-id": "456", }) @@ -230,7 +228,7 @@ def test_invalid_or_mixed_source_inputs_fail_before_network(self): with self.subTest(changes=changes): self.environment = {**original, **changes} with self.assertRaises(SystemExit): - self.execute("validate-dispatch", []) + self.execute("validate-source", []) self.opener.open.assert_not_called() self.assertFalse((self.directory / "output.txt").exists()) @@ -246,7 +244,7 @@ def test_forged_source_runs_fail_before_artifact_download_or_login(self): with self.subTest(changes=changes): self.source_run = {**original, **changes} with self.assertRaises(SystemExit): - self.execute("validate-dispatch", self.source_responses()) + self.execute("validate-source", self.source_responses()) self.assertEqual(self.opener.open.call_count, 2) self.assertFalse((self.directory / "output.txt").exists()) self.token.assert_not_called() @@ -267,7 +265,7 @@ def test_foreign_expired_or_oversized_artifacts_fail_closed(self): with self.subTest(changes=changes): self.artifact = {**original, **changes} with self.assertRaises(SystemExit): - self.execute("validate-dispatch", self.source_responses()) + self.execute("validate-source", self.source_responses()) self.assertFalse((self.directory / "output.txt").exists()) self.token.assert_not_called() @@ -361,7 +359,7 @@ def test_queued_request_preserves_the_existing_batch_receipt_contract(self): def test_manual_pr_selection_uses_the_same_coordinator_without_source_artifacts(self): self.environment.update({name: "" for name in action.SOURCE_INPUTS}, DISPATCH_PR="27") - self.execute("validate-dispatch", self.responses(self.project)) + self.execute("validate-source", self.responses(self.project)) self.assertEqual(self.action_outputs(), {"automatic": "false"}) (self.directory / "output.txt").unlink() self.execute("preflight", self.responses(self.project, self.pull)) @@ -407,7 +405,7 @@ def test_other_callers_can_still_use_the_same_workflow_filename(self): def test_coordinator_preflight_revalidates_after_source_download(self): self.write_source() - self.execute("validate-dispatch", self.source_responses()) + self.execute("validate-source", self.source_responses()) (self.directory / "output.txt").unlink() self.artifact["expired"] = True with self.assertRaises(SystemExit): @@ -416,223 +414,6 @@ def test_coordinator_preflight_revalidates_after_source_download(self): self.token.assert_not_called() -class TeamMemoryGenericDispatchTests(unittest.TestCase): - execute = memory_tests.TeamMemoryActionTests.execute - action_outputs = memory_tests.TeamMemoryActionTests.action_outputs - responses = TeamMemoryCoordinatorTests.responses - write_source = TeamMemoryCoordinatorTests.write_source - - def setUp(self): - TeamMemoryCoordinatorTests.setUp(self) - self.repository = "microsoft/vscode-gradle" - self.project.update(full_name=self.repository, default_branch="develop") - self.source_metadata.update( - repository=self.repository, base_ref="develop", - workflow_ref=self.repository + "/" + action.DISPATCH_WORKFLOW + "@refs/heads/develop", - ) - self.push.update(repository={"id": 100, "full_name": self.repository}, ref="refs/heads/develop") - TeamMemoryCoordinatorTests.select_dispatcher(self) - self.environment.update( - GITHUB_REPOSITORY=self.repository, GITHUB_REF="refs/heads/develop", - COORDINATOR_REPOSITORY="microsoft/vscode-java-pack", - COORDINATOR_WORKFLOW="team-memory-coordinator.yml", COORDINATOR_REF="main", - DISPATCH_TOKEN="fake-dispatch-token", - ) - self.target_project = {"id": 200, "full_name": "microsoft/vscode-java-pack", "default_branch": "main"} - self.target_workflow = {"id": 300, "path": action.COORDINATOR_WORKFLOW, "state": "active"} - self.target_branch = {"name": "main", "commit": {"sha": self.tip}} - - def target_responses(self): - return self.responses(self.target_project, self.target_workflow, self.target_branch) - - def acknowledgement(self, status=204): - response = Response(b"") - response.status = status - return response - - def test_generic_artifact_preserves_exact_consumer_schema_and_full_push(self): - self.event["commits"][0]["message"] = "UNTRUSTED_COMMIT_TEXT" - self.event["repository"]["description"] = "UNTRUSTED_DESCRIPTION" - self.execute("prepare-dispatch", self.responses(self.project)) - path = pathlib.Path(self.action_outputs()["source-event-path"]) - self.assertEqual(path, self.directory / "issuelens-team-memory-source" / "source-event.json") - self.assertEqual(json.loads(path.read_bytes()), self.snapshot) - self.assertLessEqual(len(path.read_bytes()), 64 * 1024) - self.assertEqual([item["id"] for item in self.snapshot["event"]["commits"]], [self.merge_sha, self.after]) - for excluded in ("UNTRUSTED", "fake-", "coordinator", "source_repository"): - self.assertNotIn(excluded, path.read_text()) - self.opener.open.assert_called_once() - self.token.assert_not_called() - - def test_cross_repo_dispatch_authenticates_target_and_keeps_develop_separate_from_main(self): - path = self.write_source() - with patch.object(action, "github_request", wraps=action.github_request) as requests: - self.execute("dispatch", self.responses(self.project) + self.target_responses() + [self.acknowledgement()]) - calls = self.opener.open.call_args_list - self.assertEqual([call.args[0].full_url for call in calls], [ - "https://api.github.com/repos/microsoft/vscode-gradle", - "https://api.github.com/repos/microsoft/vscode-java-pack", - "https://api.github.com/repos/microsoft/vscode-java-pack/actions/workflows/team-memory-coordinator.yml", - "https://api.github.com/repos/microsoft/vscode-java-pack/branches/main", - "https://api.github.com/repos/microsoft/vscode-java-pack/actions/workflows/team-memory-coordinator.yml/dispatches", - ]) - self.assertEqual([call.kwargs["token"] for call in requests.call_args_list], - [None] + [self.environment["DISPATCH_TOKEN"]] * 4) - self.assertEqual([call.args[0].get_method() for call in calls], ["GET"] * 4 + ["POST"]) - self.assertTrue(all(call.kwargs["timeout"] == 30 for call in calls)) - request = calls[-1].args[0] - self.assertEqual(json.loads(request.data), { - "ref": "main", "inputs": { - "source_repository": self.repository, "source_run_id": "123456", - "source_run_attempt": "2", "source_artifact_id": "456", - }, - }) - self.assertIsNone(self.builder.call_args.args[0].redirect_request(None, None, None, None, None, None)) - for token in (self.environment["GH_TOKEN"], self.environment["DISPATCH_TOKEN"]): - self.assertNotIn(token.encode(), request.data) - self.assertNotIn(token, path.read_text() + self.output.getvalue()) - self.token.assert_not_called() - - def test_generic_commit_inventory_exact_limit_and_artifact_budget(self): - self.event["commits"] = [{"id": f"{index:040x}"} for index in range(1, action.MAX_PUSH_COMMITS)] - self.event["commits"].append({"id": self.after}) - self.execute("prepare-dispatch", self.responses(self.project)) - path = pathlib.Path(self.action_outputs()["source-event-path"]) - content = path.read_bytes() - self.assertLessEqual(len(content), 64 * 1024) - self.assertEqual(len(json.loads(content)["event"]["commits"]), 1000) - (self.directory / "output.txt").unlink() - path.unlink() - self.event["commits"].append({"id": f"{action.MAX_PUSH_COMMITS:040x}"}) - with self.assertRaisesRegex(SystemExit, "inventory"): - self.execute("prepare-dispatch", self.responses(self.project)) - self.assertFalse(path.exists()) - self.assertFalse((self.directory / "output.txt").exists()) - self.token.assert_not_called() - - def test_explicit_same_repository_target_still_uses_generic_four_input_contract(self): - self.environment["COORDINATOR_REPOSITORY"] = self.repository - self.target_project = self.project - self.environment["COORDINATOR_REF"] = "develop" - self.target_branch["name"] = "develop" - self.write_source() - self.execute("dispatch", self.responses(self.project) + self.target_responses() + [self.acknowledgement()]) - payload = json.loads(self.opener.open.call_args.args[0].data) - self.assertEqual(payload["ref"], "develop") - self.assertEqual(payload["inputs"]["source_repository"], self.repository) - - def test_target_branch_path_is_url_encoded_and_not_used_as_source_branch(self): - self.environment["COORDINATOR_REF"] = "release/1.0" - self.target_branch["name"] = "release/1.0" - self.write_source() - self.execute("dispatch", self.responses(self.project) + self.target_responses() + [self.acknowledgement()]) - self.assertTrue(self.opener.open.call_args_list[-2].args[0].full_url.endswith("/branches/release%2F1.0")) - self.assertEqual(json.loads(self.opener.open.call_args.args[0].data)["ref"], "release/1.0") - - def test_invalid_and_partial_targets_fail_before_reads_upload_or_dispatch(self): - original = self.environment.copy() - invalid = { - "COORDINATOR_REPOSITORY": ("", "https://github.com/a/b", "a/b/c", "../repo", "a/b?x", "a/b\n", "-a/b"), - "COORDINATOR_WORKFLOW": ("", ".github/workflows/team.yml", "../team.yml", "team.yml/dispatches", - "team.json", "team.yml?x", "team.yml\n", "team;echo.yml"), - "COORDINATOR_REF": ("", "refs/heads/main", "../main", "main..next", "main.lock", "main\n", - "main?x", "main@{0}", "main;echo", "/main", "main//next", "main/", "x" * 256), - } - for name, values in invalid.items(): - for value in values: - for command in ("prepare-dispatch", "dispatch"): - with self.subTest(name=name, value=value, command=command): - self.environment = {**original, name: value} - with self.assertRaises(SystemExit): - self.execute(command, []) - self.opener.open.assert_not_called() - self.token.assert_not_called() - self.assertFalse((self.directory / "output.txt").exists()) - self.assertFalse((self.directory / "issuelens-team-memory-source").exists()) - - def test_target_authentication_and_identity_failures_stop_before_post(self): - self.write_source() - cases = [ - ([{**self.target_project, "full_name": "other/repo"}], 2), - ([{**self.target_project, "id": True}], 2), - ([self.target_project, {**self.target_workflow, "path": ".github/workflows/other.yml"}], 3), - ([self.target_project, {**self.target_workflow, "state": "disabled_manually"}], 3), - ([self.target_project, self.target_workflow, {**self.target_branch, "name": "develop"}], 4), - ([self.target_project, self.target_workflow, {"name": "main", "commit": {"sha": "short"}}], 4), - ] - for values, count in cases: - with self.subTest(values=values): - with self.assertRaises(SystemExit): - self.execute("dispatch", self.responses(self.project, *values)) - self.assertEqual(self.opener.open.call_count, count) - self.assertTrue(all(call.args[0].get_method() == "GET" for call in self.opener.open.call_args_list)) - for status in (302, 403, 404, 500): - with self.subTest(status=status): - failure = urllib.error.HTTPError("https://api.github.com", status, "PRIVATE DETAIL", {}, None) - with self.assertRaises(SystemExit) as raised: - self.execute("dispatch", self.responses(self.project) + [failure]) - self.assertNotIn("PRIVATE DETAIL", str(raised.exception)) - self.assertEqual(self.opener.open.call_count, 2) - self.token.assert_not_called() - - def test_dispatch_revalidates_source_snapshot_before_target_reads_or_writes(self): - original_snapshot, original_environment = copy.deepcopy(self.snapshot), self.environment.copy() - for change in ("workflow_sha", "workflow_path", "branch", "repository", "metadata", "body", - "created", "deleted", "forced", "duplicate", "truncated", "inventory_limit", "oversized"): - with self.subTest(change=change): - self.snapshot, self.environment = copy.deepcopy(original_snapshot), original_environment.copy() - if change == "workflow_sha": - self.environment["GITHUB_WORKFLOW_SHA"] = self.tip - elif change == "workflow_path": - self.environment["GITHUB_WORKFLOW_REF"] = self.repository + "/.github/workflows/other.yml@refs/heads/develop" - elif change == "branch": - self.environment["GITHUB_REF"] = "refs/heads/main" - elif change == "repository": - self.snapshot["event"]["repository"]["id"] = 101 - elif change == "metadata": - self.snapshot["metadata"]["run_attempt"] = True - elif change == "body": - self.snapshot["event"]["commits"][0]["message"] = "UNTRUSTED" - elif change in ("created", "deleted", "forced"): - self.snapshot["event"][change] = True - elif change == "duplicate": - self.snapshot["event"]["commits"].append({"id": self.after}) - elif change == "truncated": - self.snapshot["event"]["commits"] = [] - elif change == "inventory_limit": - self.snapshot["event"]["commits"] *= action.MAX_PUSH_COMMITS - self.write_source(b"x" * (action.MAX_SOURCE_BYTES + 1) if change == "oversized" else None) - with self.assertRaises(SystemExit): - self.execute("dispatch", self.responses(self.project)) - self.assertLessEqual(self.opener.open.call_count, 1) - self.assertTrue(all(call.args[0].get_method() == "GET" for call in self.opener.open.call_args_list)) - self.token.assert_not_called() - - def test_empty_tokens_and_invalid_artifact_ids_never_dispatch(self): - original = self.environment.copy() - self.write_source() - for changes in ({"GH_TOKEN": ""}, {"DISPATCH_TOKEN": ""}, {"DISPATCH_TOKEN": " \t"}, - {"SOURCE_ARTIFACT_ID": "0"}, {"SOURCE_ARTIFACT_ID": "1;echo"}, {"SOURCE_ARTIFACT_ID": "01"}): - with self.subTest(changes=changes): - self.environment = {**original, **changes} - with self.assertRaises(SystemExit): - self.execute("dispatch", self.responses(self.project)) - self.assertLessEqual(self.opener.open.call_count, 1) - self.token.assert_not_called() - - def test_unacknowledged_or_unknown_generic_dispatch_is_not_retried(self): - self.write_source() - for response in (self.acknowledgement(202), self.acknowledgement(302), OSError("PRIVATE DETAIL"), - urllib.error.HTTPError("https://api.github.com", 403, "PRIVATE DETAIL", {}, None)): - with self.subTest(response=response): - with self.assertRaises(SystemExit) as raised: - self.execute("dispatch", self.responses(self.project) + self.target_responses() + [response]) - self.assertNotIn("PRIVATE DETAIL", str(raised.exception)) - self.assertEqual(self.opener.open.call_count, 5) - self.assertEqual(sum(call.args[0].get_method() == "POST" - for call in self.opener.open.call_args_list), 1) - self.assertNotIn("accepted", self.output.getvalue()) - self.token.assert_not_called() if __name__ == "__main__": diff --git a/tests/test_team_memory_workflow.py b/tests/test_team_memory_workflow.py index c77730b..505db2f 100644 --- a/tests/test_team_memory_workflow.py +++ b/tests/test_team_memory_workflow.py @@ -65,7 +65,7 @@ def test_default_branch_push_and_manual_target(self): def test_preflight_precedes_pinned_login_and_submission(self): self.assertEqual(self.action_metadata["runs"]["using"], "composite") - preflight, login, submit = self.action_metadata["runs"]["steps"] + verify, download, preflight, login, submit = self.action_metadata["runs"]["steps"] self.assertEqual(preflight["id"], "preflight") self.assertRegex(login["uses"], r"^azure/login@[0-9a-f]{40}\Z") for step in (login, submit): @@ -121,16 +121,22 @@ def test_all_issuelens_requests_share_the_wiki_queue(self): def test_dispatcher_has_no_agent_credentials_or_invocation(self): self.assertEqual(self.dispatch_job["timeout-minutes"], "10") steps = self.dispatch_job["steps"] - self.assertEqual(len(steps), 2) - self.assertEqual(steps[1], { - "name": "Queue team-memory request", "uses": "./.github/actions/queue-team-memory", + self.assertEqual(len(steps), 4) + prepare, upload, dispatch = steps[1:] + self.assertEqual(prepare["run"], "python3 -I .github/actions/issuelens/issuelens_action.py prepare-source") + self.assertEqual(prepare["env"], {"GH_TOKEN": "${{ github.token }}"}) + self.assertEqual(dispatch["uses"], "./.github/actions/queue-team-memory") + self.assertEqual(dispatch["with"]["coordinator-workflow"], "team-memory-coordinator.yml") + self.assertEqual(json.loads(dispatch["with"]["workflow-inputs"]), { + "source_run_id": "${{ github.run_id }}", "source_run_attempt": "${{ github.run_attempt }}", + "source_artifact_id": "${{ steps.artifact.outputs.artifact-id }}", }) for forbidden in ("secrets.", "id-token", "azure/login", "agent-url", "request-type:", "pull_request"): self.assertNotIn(forbidden, self.dispatch_source + self.queue_source) - def test_queue_action_owns_preparation_upload_and_single_dispatch(self): + def test_source_workflow_owns_preparation_upload_and_dispatch_inputs(self): self.assertEqual(self.queue_metadata["runs"]["using"], "composite") - prepare, upload, dispatch = self.queue_metadata["runs"]["steps"] + prepare, upload, dispatch = self.dispatch_job["steps"][1:] self.assertEqual(prepare["id"], "source") self.assertEqual(upload["id"], "artifact") self.assertRegex(upload["uses"], r"^actions/upload-artifact@[0-9a-f]{40}$") @@ -139,51 +145,37 @@ def test_queue_action_owns_preparation_upload_and_single_dispatch(self): "path": "${{ steps.source.outputs.source-event-path }}", "if-no-files-found": "error", "retention-days": "7", }) - targets = ("coordinator-repository", "coordinator-workflow", "coordinator-ref") - self.assertEqual(set(self.queue_metadata["inputs"]), {"source-token", "dispatch-token", *targets}) - for name, input_metadata in self.queue_metadata["inputs"].items(): - self.assertEqual(input_metadata["default"], "" if name in targets else "${{ github.token }}") - self.assertEqual(input_metadata["required"], "false") - target_env = {name.upper().replace("-", "_"): "${{ inputs." + name + " }}" for name in targets} - self.assertEqual(prepare["env"], { - "GITHUB_ACTION_PATH": "${{ github.action_path }}", "GH_TOKEN": "${{ inputs.source-token }}", - **target_env, - }) - self.assertEqual(dispatch["env"], { - "GITHUB_ACTION_PATH": "${{ github.action_path }}", "GH_TOKEN": "${{ inputs.source-token }}", - "DISPATCH_TOKEN": "${{ inputs.dispatch-token }}", - "SOURCE_ARTIFACT_ID": "${{ steps.artifact.outputs.artifact-id }}", - **target_env, - }) - for step, command in ((prepare, "prepare-dispatch"), (dispatch, "dispatch")): - self.assertEqual(step["shell"], "bash") - self.assertEqual(step["run"], f'python3 -I "$GITHUB_ACTION_PATH/../issuelens/issuelens_action.py" {command}') - self.assertNotIn("${{", step["run"]) + self.assertEqual(prepare["env"], {"GH_TOKEN": "${{ github.token }}"}) + for step in (prepare, upload, dispatch): self.assertNotIn("if", step) self.assertNotIn("continue-on-error", step) - output = self.queue_metadata["outputs"]["source-artifact-id"] - self.assertEqual(output["value"], "${{ steps.artifact.outputs.artifact-id }}") - self.assertIn("does not imply", output["description"]) - for forbidden in ("concurrency", "permissions", "actions/checkout", "azure/login", "pip install"): + only_dispatch, = self.queue_metadata["runs"]["steps"] + self.assertEqual(only_dispatch["run"], 'python3 -I "$GITHUB_ACTION_PATH/dispatch.py"') + for forbidden in ("concurrency", "permissions", "actions/checkout", "azure/login", "pip install", + "upload-artifact", "source-token", "../issuelens"): self.assertNotIn(forbidden, self.queue_source) - def test_coordinator_validates_source_before_download_and_agent_login(self): - checkout, verify, download, invoke = self.steps + def test_invocation_action_validates_source_before_download_and_agent_login(self): + checkout, invoke = self.steps + verify, download, preflight, login, submit = self.action_metadata["runs"]["steps"] self.assertEqual(verify["id"], "source") - self.assertEqual(verify["run"], "python3 -I .github/actions/issuelens/issuelens_action.py validate-dispatch") - self.assertEqual(verify["env"]["GH_TOKEN"], "${{ github.token }}") + self.assertEqual(verify["run"], 'python3 -I "$GITHUB_ACTION_PATH/issuelens_action.py" validate-source') + self.assertEqual(verify["env"]["GH_TOKEN"], "${{ inputs.github-token }}") + self.assertIn("inputs.request-type == 'team-memory'", verify["if"]) + self.assertTrue(all("run" not in step for step in self.steps)) self.assertRegex(download["uses"], r"^actions/download-artifact@[0-9a-f]{40}$") self.assertEqual(download["if"], "steps.source.outputs.automatic == 'true'") self.assertEqual(download["with"], { "artifact-ids": "${{ steps.source.outputs.source-artifact-id }}", "run-id": "${{ steps.source.outputs.source-run-id }}", - "repository": "${{ github.repository }}", "github-token": "${{ github.token }}", + "repository": "${{ github.repository }}", "github-token": "${{ inputs.github-token }}", "path": "${{ runner.temp }}/issuelens-team-memory-source", "digest-mismatch": "error", }) for name in ("source_run_id", "source_run_attempt", "source_artifact_id"): - self.assertEqual(verify["env"][name.upper()], "${{ inputs." + name + " }}") + self.assertEqual(verify["env"][name.upper()], "${{ inputs." + name.replace("_", "-") + " }}") self.assertEqual(invoke["with"][name.replace("_", "-")], "${{ inputs." + name + " }}") - self.assertTrue(all("${{" not in step["run"] for step in self.steps if "run" in step)) + self.assertEqual(login["if"], "steps.preflight.outputs.eligible == 'true'") + self.assertTrue(all("${{" not in step["run"] for step in self.action_metadata["runs"]["steps"] if "run" in step)) def test_job_timeout_has_setup_and_receipt_headroom(self): token_seconds, connection_seconds, stream_seconds = 60, 60, 15 * 60 @@ -239,12 +231,11 @@ def test_queue_action_remote_example_is_pinned_and_preserves_pilot_scope(self): invocation = yaml.load(example, Loader=yaml.BaseLoader)[0] self.assertEqual(invocation["uses"], "microsoft/IssueLens/.github/actions/queue-team-memory@FULL_COMMIT_SHA") self.assertEqual(invocation["with"], { - "source-token": "${{ github.token }}", - "dispatch-token": "${{ steps.dispatch-token.outputs.token }}", + "coordinator-workflow": "team-memory-coordinator.yml", + "workflow-inputs": '{"source_run_id":"${{ github.run_id }}", "source_run_attempt":"${{ github.run_attempt }}", "source_artifact_id":"${{ steps.artifact.outputs.artifact-id }}"}', }) self.assertNotIn("actions/checkout", example) - self.assertIn("only `microsoft/IssueLens`", guide) - self.assertIn("does not enable workflows", guide) + self.assertIn("does not prepare", guide) self.assertIn("executes in the caller", guide) self.assertIn("Actions write", guide) self.assertIn("does not expand repository access", guide) @@ -255,16 +246,15 @@ def test_queue_action_generic_example_matches_the_four_input_contract(self): invocation = yaml.load(example, Loader=yaml.BaseLoader)[0] self.assertEqual(invocation["uses"], "microsoft/IssueLens/.github/actions/queue-team-memory@FULL_COMMIT_SHA") self.assertEqual(invocation["with"], { - "source-token": "${{ github.token }}", "dispatch-token": "${{ steps.dispatch-token.outputs.token }}", + "dispatch-token": "${{ steps.dispatch-token.outputs.token }}", "coordinator-repository": "microsoft/vscode-java-pack", "coordinator-workflow": "team-memory-coordinator.yml", "coordinator-ref": "main", + "workflow-inputs": '{"source_repository":"${{ github.repository }}", "source_run_id":"${{ github.run_id }}", "source_run_attempt":"${{ github.run_attempt }}", "source_artifact_id":"${{ steps.artifact.outputs.artifact-id }}"}', }) self.assertEqual(set(invocation["with"]), set(self.queue_metadata["inputs"])) for field in ("source_repository", "source_run_id", "source_run_attempt", "source_artifact_id"): - self.assertIn(f"`{field}`", guide) + self.assertIn(field, guide) self.assertIn("develop", guide) - self.assertIn("workflow_sha", guide) - self.assertIn("SHA-256 digest", guide) self.assertNotIn("actions/checkout", example) def test_agents_do_not_depend_on_the_workflow_contract(self): @@ -583,14 +573,13 @@ def test_helper_cli_runs_outside_the_repository_in_isolated_mode(self): self.assertNotIn("ImportError", result.stderr) self.assertFalse((self.directory / "output.txt").exists()) - def test_queue_helper_runs_from_downloaded_bundle_outside_checkout(self): - bundle = self.directory / "downloaded-action" / ".github" / "actions" - for source in (ACTION_DIR, QUEUE_ACTION_DIR): - shutil.copytree(source, bundle / source.name, ignore=shutil.ignore_patterns("__pycache__")) - helper = bundle / "queue-team-memory" / ".." / "issuelens" / "issuelens_action.py" + def test_source_helper_runs_from_downloaded_invocation_action_outside_checkout(self): + bundle = self.directory / "downloaded-action" + shutil.copytree(ACTION_DIR, bundle, ignore=shutil.ignore_patterns("__pycache__")) + helper = bundle / "issuelens_action.py" caller = self.directory / "caller" caller.mkdir() - for command in ("prepare-dispatch", "dispatch"): + for command in ("prepare-source",): with self.subTest(command=command): result = subprocess.run( [sys.executable, "-I", str(helper), command], diff --git a/tests/test_workflow_dispatch_action.py b/tests/test_workflow_dispatch_action.py new file mode 100644 index 0000000..746461a --- /dev/null +++ b/tests/test_workflow_dispatch_action.py @@ -0,0 +1,216 @@ +import contextlib +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +import urllib.error +from unittest.mock import Mock, patch + +import yaml + + +ACTION = Path(__file__).parents[1] / ".github" / "actions" / "queue-team-memory" + + +class Response(io.BytesIO): + def __init__(self, value=b"", status=200): + super().__init__(value if isinstance(value, bytes) else json.dumps(value).encode()) + self.status = status + + +class WorkflowDispatchTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.directory = Path(temporary.name) + self.bundle = self.directory / "standalone-action" + shutil.copytree(ACTION, self.bundle, ignore=shutil.ignore_patterns("__pycache__")) + spec = importlib.util.spec_from_file_location("standalone_dispatch", self.bundle / "dispatch.py") + self.client = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.client) + self.environment = { + "COORDINATOR_REPOSITORY": "example/central", "COORDINATOR_WORKFLOW": "queue.yml", + "COORDINATOR_REF": "main", "DISPATCH_TOKEN": "fixture-dispatch-credential", + "WORKFLOW_INPUTS": '{"job":"fixture","request_id":"123"}', + "GH_TOKEN": "fixture-source-credential", + "GITHUB_REPOSITORY": "example/source", "GITHUB_REF": "refs/heads/develop", + } + self.project = {"id": 200, "full_name": "example/central"} + self.workflow = {"id": 300, "path": ".github/workflows/queue.yml", "state": "active"} + self.branch = {"name": "main", "commit": {"sha": "f" * 40}} + self.output = io.StringIO() + + def responses(self, *values): + return [Response(value) for value in values] + + def execute(self, responses): + self.opener = Mock() + self.opener.open.side_effect = responses + with patch.object(self.client.os, "environ", self.environment), contextlib.redirect_stdout(self.output), \ + patch.object(self.client.urllib.request, "build_opener", return_value=self.opener) as builder: + self.builder = builder + self.client.run() + + def success(self): + return self.responses(self.project, self.workflow, self.branch) + [Response(status=204)] + + def test_copied_action_is_standalone_and_has_only_dispatch_wiring(self): + metadata = yaml.load((self.bundle / "action.yml").read_text(), Loader=yaml.BaseLoader) + self.assertEqual(set(metadata["inputs"]), { + "dispatch-token", "coordinator-repository", "coordinator-workflow", "coordinator-ref", "workflow-inputs", + }) + self.assertEqual(metadata["inputs"]["coordinator-repository"]["default"], "${{ github.repository }}") + self.assertEqual(metadata["inputs"]["coordinator-ref"]["default"], "${{ github.ref_name }}") + self.assertEqual(metadata["inputs"]["dispatch-token"]["default"], "${{ github.token }}") + self.assertEqual(metadata["inputs"]["workflow-inputs"]["default"], "{}") + self.assertEqual(metadata["inputs"]["coordinator-workflow"]["required"], "true") + step, = metadata["runs"]["steps"] + self.assertEqual(step["run"], 'python3 -I "$GITHUB_ACTION_PATH/dispatch.py"') + self.assertNotIn("${{", step["run"]) + self.assertNotIn("outputs", metadata) + for forbidden in ("../issuelens", "source-token", "SOURCE_", "GITHUB_EVENT", "RUNNER_TEMP", + "upload-artifact", "download-artifact", "azure/login", "wiki", "prepare_push"): + self.assertNotIn(forbidden, (self.bundle / "dispatch.py").read_text() + json.dumps(metadata)) + self.assertFalse((self.directory / "issuelens").exists()) + self.execute(self.success()) + self.assertEqual(self.opener.open.call_count, 4) + + def test_single_authenticated_post_preserves_arbitrary_caller_inputs_and_branch_independence(self): + self.execute(self.success()) + requests = [call.args[0] for call in self.opener.open.call_args_list] + self.assertEqual([request.full_url for request in requests], [ + "https://api.github.com/repos/example/central", + "https://api.github.com/repos/example/central/actions/workflows/queue.yml", + "https://api.github.com/repos/example/central/branches/main", + "https://api.github.com/repos/example/central/actions/workflows/queue.yml/dispatches", + ]) + self.assertEqual([request.get_method() for request in requests], ["GET"] * 3 + ["POST"]) + self.assertEqual(json.loads(requests[-1].data), {"ref": "main", "inputs": {"job": "fixture", "request_id": "123"}}) + self.assertTrue(all(request.get_header("Authorization") == "Bearer " + self.environment["DISPATCH_TOKEN"] + for request in requests)) + self.assertTrue(all(call.kwargs["timeout"] == 30 for call in self.opener.open.call_args_list)) + self.assertIsNone(self.builder.call_args.args[0].redirect_request(None, None, None, None, None, None)) + for credential in (self.environment["GH_TOKEN"], self.environment["DISPATCH_TOKEN"]): + self.assertNotIn(credential.encode(), requests[-1].data) + self.assertNotIn(credential, self.output.getvalue()) + self.assertIn("job completion are not confirmed", self.output.getvalue()) + + def test_same_repo_pilot_and_cross_repo_job_payloads_are_caller_owned(self): + cases = [ + ("example/central", {"source_run_id": "123456", "source_run_attempt": "2", "source_artifact_id": "456"}), + ("example/source", {"source_repository": "example/source", "source_run_id": "123456", + "source_run_attempt": "2", "source_artifact_id": "456"}), + ("unrelated/source", {"pull_request_number": "27"}), + ] + for source, inputs in cases: + with self.subTest(source=source): + self.environment["GITHUB_REPOSITORY"] = source + self.environment["WORKFLOW_INPUTS"] = json.dumps(inputs) + self.execute(self.success()) + self.assertEqual(json.loads(self.opener.open.call_args.args[0].data), + {"ref": "main", "inputs": inputs}) + + def test_branch_path_is_url_encoded(self): + self.environment["COORDINATOR_REF"] = "release/1.0" + self.branch["name"] = "release/1.0" + self.execute(self.success()) + self.assertTrue(self.opener.open.call_args_list[2].args[0].full_url.endswith("/branches/release%2F1.0")) + + def test_invalid_targets_tokens_and_payloads_fail_before_network(self): + original = self.environment.copy() + invalid = { + "COORDINATOR_REPOSITORY": ("", "../repo", "https://github.com/a/b", "a/b/c", "a/b\n", "a/b?x"), + "COORDINATOR_WORKFLOW": ("", "../queue.yml", ".github/workflows/queue.yml", "queue.json", "queue.yml\n"), + "COORDINATOR_REF": ("", "refs/heads/main", "main..next", "main.lock", "main\n", "main@{0}", "main;echo", + "/main", "main//next", "main/", "x" * 256), + "DISPATCH_TOKEN": ("", " ", "token\nnext"), + "WORKFLOW_INPUTS": ("", "[]", "null", "{", '{"x":true}', '{"x":1}', '{"x":{}}', '{"x":[]}', + '{"x":"a","x":"b"}', '{"x\\ny":"a"}', "x" * (64 * 1024 + 1), + json.dumps({f"key{i}": "v" for i in range(11)}), + json.dumps({"credential": self.environment["DISPATCH_TOKEN"]})), + } + for name, values in invalid.items(): + for value in values: + with self.subTest(name=name, value=value[:40]): + self.environment = {**original, name: value} + with self.assertRaises(SystemExit) as raised: + self.execute([]) + self.opener.open.assert_not_called() + self.assertNotIn(original["DISPATCH_TOKEN"], str(raised.exception)) + + def test_exact_payload_budget_and_input_count(self): + self.environment["WORKFLOW_INPUTS"] = json.dumps({f"key{i}": "value" for i in range(10)}) + self.execute(self.success()) + reference = self.environment["COORDINATOR_REF"] + overhead = len(json.dumps({"ref": reference, "inputs": {"x": ""}}).encode()) + self.environment["WORKFLOW_INPUTS"] = json.dumps({"x": "a" * (self.client.MAX_BYTES - overhead)}) + self.execute(self.success()) + self.assertEqual(len(self.opener.open.call_args.args[0].data), self.client.MAX_BYTES) + self.environment["WORKFLOW_INPUTS"] = json.dumps({"x": "a" * (self.client.MAX_BYTES - overhead + 1)}) + with self.assertRaisesRegex(SystemExit, "payload exceeds"): + self.execute([]) + self.opener.open.assert_not_called() + + def test_target_authentication_identity_and_bounded_reads_fail_before_post(self): + cases = [ + ([{**self.project, "full_name": "other/target"}], 1), + ([{**self.project, "id": True}], 1), + ([self.project, {**self.workflow, "path": ".github/workflows/other.yml"}], 2), + ([self.project, {**self.workflow, "state": "disabled_manually"}], 2), + ([self.project, self.workflow, {**self.branch, "name": "develop"}], 3), + ([self.project, self.workflow, {"name": "main", "commit": {"sha": "0" * 40}}], 3), + ] + for values, count in cases: + with self.subTest(values=values): + with self.assertRaises(SystemExit): + self.execute(self.responses(*values)) + self.assertEqual(self.opener.open.call_count, count) + self.assertTrue(all(call.args[0].get_method() == "GET" for call in self.opener.open.call_args_list)) + for response in (Response(b"x" * (self.client.MAX_BYTES + 1)), Response(b"PRIVATE INVALID JSON"), + Response(b"[]"), Response(status=302), + ValueError("PRIVATE TRANSPORT DETAIL"), + urllib.error.HTTPError("https://api.github.com", 403, "PRIVATE DETAIL", {}, None)): + with self.subTest(response=response): + with self.assertRaises(SystemExit) as raised: + self.execute([response]) + self.assertNotIn("PRIVATE", str(raised.exception)) + self.opener.open.assert_called_once() + + def test_unknown_or_unacknowledged_post_is_not_retried_or_reported_as_job_success(self): + for response in (Response(status=202), Response(status=302), OSError("PRIVATE DETAIL"), + ValueError("PRIVATE TRANSPORT DETAIL"), + urllib.error.HTTPError("https://api.github.com", 500, "PRIVATE DETAIL", {}, None)): + with self.subTest(response=response): + with self.assertRaises(SystemExit) as raised: + self.execute(self.responses(self.project, self.workflow, self.branch) + [response]) + self.assertNotIn("PRIVATE", str(raised.exception)) + self.assertEqual(self.opener.open.call_count, 4) + self.assertEqual(sum(call.args[0].get_method() == "POST" + for call in self.opener.open.call_args_list), 1) + self.assertNotIn("accepted", self.output.getvalue()) + + def test_output_failure_does_not_deny_an_acknowledged_dispatch(self): + with patch("builtins.print", side_effect=OSError("PRIVATE OUTPUT DETAIL")): + with self.assertRaisesRegex(SystemExit, "Dispatch acknowledged but output unavailable") as raised: + self.execute(self.success()) + self.assertNotIn("PRIVATE", str(raised.exception)) + self.assertEqual(self.opener.open.call_count, 4) + + def test_isolated_cli_works_with_only_copied_dispatch_folder(self): + environment = {**self.environment, "WORKFLOW_INPUTS": "invalid PRIVATE INPUT"} + result = subprocess.run([sys.executable, "-I", str(self.bundle / "dispatch.py")], + cwd=self.directory, env=environment, capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 1) + self.assertIn("workflow-inputs must be a JSON object", result.stderr) + self.assertNotIn("ImportError", result.stderr) + self.assertNotIn("PRIVATE", result.stderr) + + +if __name__ == "__main__": + unittest.main() From 293649325caa3df4996a211d1248f0b461b65e9b Mon Sep 17 00:00:00 2001 From: Changyong Gong Date: Fri, 9 Oct 2026 17:41:15 +0800 Subject: [PATCH 3/3] Replace team-memory artifacts with authorized range reconciliation Centralize source/run validation, bounded commit pagination and PR discovery in the invocation action. Keep source workflows artifact-free and dispatch standalone, with trusted cross-source configuration and independent read credentials. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/issuelens/README.md | 159 +++--- .github/actions/issuelens/action.yml | 57 +- .github/actions/issuelens/issuelens_action.py | 280 +++++----- .github/actions/queue-team-memory/README.md | 38 +- .github/copilot-instructions.md | 43 +- .github/workflows/team-memory-coordinator.yml | 16 +- .github/workflows/team-memory-post-merge.yml | 41 +- docs/guide.md | 72 +-- github_app_mcp/README.md | 22 +- tests/test_issue_triage_workflow.py | 4 +- tests/test_team_memory_coordinator.py | 496 +++++++++--------- tests/test_team_memory_workflow.py | 110 ++-- tests/test_workflow_dispatch_action.py | 5 +- 13 files changed, 664 insertions(+), 679 deletions(-) diff --git a/.github/actions/issuelens/README.md b/.github/actions/issuelens/README.md index 2f84884..9314e00 100644 --- a/.github/actions/issuelens/README.md +++ b/.github/actions/issuelens/README.md @@ -6,9 +6,9 @@ agent with Azure OIDC, and invokes it through a shared bounded HTTP/SSE client. It is a client of the agent, not part of the hosted agent runtime. The normal path prepares the request, logs in with the pinned `azure/login` -action, and submits the request. Coordinated team-memory inputs first run -source verification and pinned artifact download inside this action; all -verification, download, and complete-range discovery precede Azure login. +action, and submits the request. Coordinated team-memory inputs select a bounded +reconciliation range; source/run validation, ancestry checks, and complete +paginated commit discovery all happen inside preflight before Azure login. The standard-library Python helper owns metadata validation, the caller's task text, bounded HTTP/SSE processing, and result validation. No inline Python, pip installation, container build, or @@ -19,7 +19,7 @@ GitHub App private key is required. | `request-type` | Preparation | Result | | --- | --- | --- | | `issue-loop` | Accept issue opened/reopened, human issue-comment created/edited, or manual issue dispatch. Preserve workflow-owned event metadata; exclude issue/comment bodies and skip PR/bot comments before login. | A completed root agent response, without a required JSON schema. The orchestrator chooses triage, planning, or no action. | -| `team-memory` | Discover and verify merged PRs introduced by a default-branch push, accept a manual single-PR request, or verify an IssueLens coordinator source artifact. | Require matching source revisions, verified wiki identity for completed work, and one outcome per PR in a push batch. Partial batches retain their receipts but fail the step. | +| `team-memory` | Discover and verify merged PRs introduced by a default-branch push, accept a manual single-PR request, or reconcile a coordinator-authorized source range. | Require matching source revisions, verified wiki identity for completed work, and one outcome per PR in a push batch. Partial batches retain their receipts but fail the step. | | `task` | Require explicit non-empty `input`, bounded to 64 KiB UTF-8. No issue-loop event metadata or maintainer-command authority is synthesized. | A completed root agent response in the requested format. | All request types validate the caller repository identity and that the caller @@ -88,8 +88,9 @@ Existing `pull_request_target: closed` callers remain supported by the action for compatibility, but need an applicable GitHub Actions event policy. The recommended push workflow does not depend on that exception. Other events and unmerged manual targets are rejected. -For event adapters the source repository is derived from the caller's GitHub context, not an input -that could redirect maintenance to another source repository. PR titles, bodies, +For direct event adapters the source repository comes from the caller's GitHub +context. Coordinated requests can select only sources explicitly allowed by the +trusted coordinator workflow, as described below. PR titles, bodies, and fork contents are not embedded in the task. IssueLens's [issue workflow](../../workflows/issue-triage.yml) and @@ -103,85 +104,84 @@ local-action step to consumer repositories; use the remote reference above. ### IssueLens Coordinator Pilot IssueLens itself now uses two workflows. The -[push workflow](../../workflows/team-memory-post-merge.yml) prepares the source -with this directory's request-owned `issuelens_action.py prepare-source` -helper and uploads the artifact with a pinned uploader, then calls the separate +[push workflow](../../workflows/team-memory-post-merge.yml) calls only the pinned [standalone dispatch action](../queue-team-memory/README.md). The generic dispatcher owns only target validation and one POST with caller-supplied workflow inputs. It never calls this action's scripts. -Source preparation uses the source read token; dispatch uses its independent -target token. Both are `github.token` for this same-repository pilot. -The source helper validates the authenticated canonical repository, exact -`.github/workflows/team-memory-post-merge.yml` on its current default branch, -and push/workflow SHA, and preserves only repository, source run/attempt, -workflow revision, before/after SHAs, and all commit IDs. It does not upload commit -messages, source code, issue/PR bodies, agent responses, or credentials. The -immutable artifact is named per run attempt and retained for seven days. -The source workflow supplies three string IDs to the standalone dispatcher's -`workflow-inputs`; it sends one `workflow_dispatch` to the coordinator on the -default branch, passing only source run, attempt, and artifact IDs. Its -repository-scoped `GITHUB_TOKEN` needs Contents read and Actions write; it has no Azure secrets, -OIDC permission, or agent invocation. - -The coordinator owns the central queue and calls only this invocation action -(plus trusted sparse checkout for the local action). It accepts either those -three identifiers or one manual `pull_request_number`, never both. -Inside this action, source validation and pinned download precede normal -preflight and Azure login. Before download, the request-owned helper verifies the source -repository, exact dispatcher path, push event, default branch, run attempt, -full head SHA, and the artifact's run identity, name, digest, size, and expiry. -The pinned downloader rejects digest mismatches. Normal preflight independently -revalidates the source and reads the bounded identity-only artifact, then runs -the same complete push discovery as direct callers. It preserves the original -push range and source provenance even if the coordinator starts at a newer -default-branch revision. Queue admission is not merge verification or proof of -wiki publication. - -The artifact remains -`${RUNNER_TEMP}/issuelens-team-memory-source/source-event.json`, uploaded as -`issuelens-team-memory-source-${GITHUB_RUN_ATTEMPT}` with seven-day retention. -JSON is at most 64 KiB, with exactly `metadata` and `event`: - -| Object | Exact fields | +There is no source checkout, preparation script, upload, or download. The source +workflow rejects created/deleted/forced refs and a workflow SHA different from +the push head, then sends five string fields: + +| Workflow input | Meaning | | --- | --- | -| `metadata` | `repository`, `repository_id`, `base_ref`, `event_name`, `event_action`, `actor_login`, `triggering_actor`, `workflow_ref`, `workflow_sha`, `run_id`, `run_attempt` | -| `event` | `repository: {id, full_name}`, `ref`, `before`, `after`, `created`, `deleted`, `forced`, `commits: [{id}, ...]`, `head_commit: {id}` | - -Repository/run/attempt IDs are integers; repository names are canonical. -Event name/action are `push`, flags are exactly `false`, and `ref` names the -source default branch. `workflow_sha`, `GITHUB_SHA`, `after`, and `head_commit.id` -match one full nonzero SHA. `before` is distinct. All 1-1000 commit IDs are -retained in order, unique full nonzero SHAs, including `after` and excluding -`before`. Actors and workflow identity come from trusted workflow context. -No target, token, body, message, or code fields are added. The source run API -does not establish original `before`; the receiver must verify this preserved -inventory against the complete authoritative fast-forward range before invocation. +| `source_repository` | Source `${{ github.repository }}` | +| `source_run_id`, `source_run_attempt` | Source push run and attempt | +| `push_before`, `push_after` | Requested ancestor and source run head, both full nonzero SHAs | + +The source needs only target Contents read and Actions write access; it has no +Azure credentials or agent invocation. The dispatcher is pinned to the previously +published `296350903a578f3bcd847b34ce85b51e90b4b919`, whose generic transport +contract is unchanged. Its target branch is explicitly `main`, independent of +the source branch. + +The coordinator owns the queue and calls only this invocation action (plus trusted +sparse checkout for the local action). It accepts all four run/range inputs or +one manual `pull_request_number`, never both, with an optional source repository. +Preflight authenticates the source, verifies the exact +`.github/workflows/team-memory-post-merge.yml` push run/attempt on its current +default branch, and requires `push_after` to match its head. It verifies that +head is still on the current default-branch ancestry, retrieves the complete +fast-forward `push_before...push_after` commit list in pages of 100, and discovers +eligible merged PRs. Missing pages, duplicate IDs, divergent ranges, more than +1,000 commits, or a default-branch ref change during discovery fail closed. +Source workflow/run metadata remains separate from the coordinator's newer +head; the requested range is never replaced with that head. No source body, +message, patch, or credential enters the agent task. + +**Trust change:** this is an authorized request to reconcile a selected range, +not proof of the original push boundary. The run API attests the source run head +but cannot establish original `before` or push flags. The receiving workflow +authorizes a validated ancestor range, and the request explicitly tells the +agent this. It must not invent an original event or use the range as evidence +that a forced push never occurred. No artifact lifetime/digest guarantee is +claimed because no artifact is used. Automatic pushes and manual PR requests share the fixed `issuelens-team-memory-wiki-microsoft-IssueLens` concurrency group, with `queue: max` and `cancel-in-progress: false`. The slot covers the complete coordinator run, including discovery, Azure OIDC login, the one agent invocation, and final result validation. GitHub retains up to 100 pending runs; further runs -are canceled when that queue is full. Monitor canceled/failed runs and artifact -expiry. Dispatch acknowledgement means GitHub accepted the request, not that +are canceled when that queue is full. Monitor canceled/failed runs. +Dispatch acknowledgement means GitHub accepted the request, not that maintenance completed. Inspect the coordinator run for its outcome. -This first rollout accepts only `microsoft/IssueLens` sources. The caller -requires the agent to verify its validated wiki destination matches -`microsoft/IssueLens` and stop without writing on a mismatch, never override -policy. The action also rejects a receipt naming another wiki. -IssueLens's existing wiki configuration remains unchanged. Java -tooling repositories are not enabled or modified by this pilot. Their future -centralized requests will need authenticated cross-repository dispatch, -source validation, and a separate queue for the shared -`microsoft/vscode-java-pack` wiki. The standalone dispatch action accepts generic -targets and caller-owned `workflow-inputs`, but does not prepare evidence or -infer `source_repository`. Java Pack's existing immutable pin remains unchanged; -its later migration must move preparation/upload into the source request path -and keep receiving provenance validation. This action's coordinated adapter -still accepts only the IssueLens pilot; it does not silently generalize wiki -scope. Reusing a workflow or composite action alone -does not move its run into the coordinator repository. +The IssueLens workflow leaves `source-repositories` at `{}`, accepting only its +own source. Its wiki configuration is unchanged. Coordinated requests require +the agent to verify that validated wiki policy names the coordinator repository, +and reject a different wiki receipt; they never override policy. + +For a later cross-repository consumer, put a reviewed JSON map such as +`source-repositories: '{"example/gradle":123456}'` in the **trusted coordinator +workflow**, not its dispatched inputs. Canonical names and immutable repository +IDs are rechecked. Cross-repository sources and coordinators must both be public; +private/internal cross-repository jobs fail before Azure login. Supply an +independent `source-github-token` with source Contents, Actions, and Pull requests +read access. `github-token` validates the coordinator, and the dispatch token +independently accesses the target workflow. Neither token nor a target/input +claim expands access or grants new job authority. A source default branch +`develop` and coordinator default branch `main` are supported independently. +The receiving workflow must remain `.github/workflows/team-memory-coordinator.yml`. + +Java Pack is unchanged and still pinned to its older revision. A separately +authorized migration must adopt these five request fields, configure its own +trusted source-ID map and source-read access, keep its wiki queue, and replace +its artifact adapter with this invocation action. Merely reusing an action +does not move its execution into the central repository. + +**Compatibility change:** coordinated `source-artifact-id`, `prepare-source`, +and `validate-source` are removed. Previously pinned consumers continue to use +their immutable code until migrated. Direct issue-loop, task, push, and manual +team-memory callers keep their existing behavior and receipt contracts. Use **Run workflow** on the coordinator with `pull_request_number` for a manual single-PR update. The push dispatcher no longer has a manual trigger. The @@ -196,7 +196,7 @@ One push produces at most one agent invocation, containing the eligible merged PRs introduced by that push. Separate pushes are not debounced or combined. A normal individual merge therefore still usually produces one invocation. -The preflight uses the trusted event's commit IDs and checks that their unique +For direct pushes, preflight uses the trusted event's commit IDs and checks that their unique count matches an authoritative fast-forward `before...after` comparison. A non-first comparison page supplies range metadata without its first-page file diffs. Metadata-only GraphQL lookups group 20 commit identities per request and @@ -229,7 +229,7 @@ commit or submitting a partial source set. | Discovery time | 180-second cooperative budget, checked around requests; an in-flight request retains its 30-second timeout | | Agent input | At most 64 KiB UTF-8 | -Missing or truncated inventories, diverged/forced pushes, new/deleted refs, +Missing or truncated inventories, divergent ranges, forced direct pushes, new/deleted direct refs, lookup errors, and exceeded limits fail before Azure login. No partial list is submitted, because an unknown source set cannot establish independent updates. A valid push with no newly merged PRs is skipped with `no_merged_pull_requests`. @@ -334,11 +334,14 @@ identity check is not authorization for any additional repositories named by a t | `request-type` | Yes | `issue-loop`, `team-memory`, or `task`. | | `input` | For `task` | Explicit task text, 1-64 KiB UTF-8; do not combine with event adapters. | | `issue-number` | For manual `issue-loop` | Positive issue number; automatic events use their containing issue. | -| `github-token` | No | Defaults to `github.token`; repository read for all types, Issues read for manual issue dispatch, Pull requests read for team memory, and Actions read for coordinated sources. | +| `github-token` | No | Defaults to `github.token`; caller Contents read, Issues read for manual issue dispatch, and Pull requests read for direct team memory. | | `pull-request-number` | For manual `team-memory` | Positive merged PR number. Pushes discover their own complete PR batch. | -| `source-run-id` | For coordinated `team-memory` | Source push run ID; reserved for the IssueLens coordinator, not an arbitrary source repository. | +| `source-repository` | No | Coordinated source canonical name; defaults to the coordinator repository. Explicit sources select the coordinated path, including manual PR requests. | +| `source-repositories` | No | Trusted coordinator workflow's allowed source-name-to-numeric-ID JSON map (4 KiB, at most 100 entries); default `{}` allows only the coordinator repository. Never populate from dispatched inputs. | +| `source-github-token` | No | Defaults to `github.token`; independent source Contents, Actions and Pull requests read access for coordinated requests. Explicitly empty values fail without fallback. | +| `source-run-id` | For coordinated automatic `team-memory` | Positive source push run ID. | | `source-run-attempt` | With `source-run-id` | Positive originating run attempt. | -| `source-artifact-id` | With `source-run-id` | Immutable identity-only artifact from that run attempt. Supply all three source inputs, never with `pull-request-number`; the coordinator downloads it before invoking the action. | +| `push-before`, `push-after` | With `source-run-id` | Full nonzero requested ancestor/source-run-head SHAs. Supply all four run/range inputs, never with `pull-request-number`. Before is not an attested original event boundary. | | `azure-client-id` | Yes | Existing Azure OIDC identity's client ID. | | `azure-tenant-id` | Yes | Tenant used by Azure login. | | `azure-subscription-id` | Yes | Subscription used by Azure login. | diff --git a/.github/actions/issuelens/action.yml b/.github/actions/issuelens/action.yml index 3282aaa..073785e 100644 --- a/.github/actions/issuelens/action.yml +++ b/.github/actions/issuelens/action.yml @@ -14,23 +14,39 @@ inputs: required: false default: '' github-token: - description: Caller repository read token; issue-loop dispatch needs Issues read, team-memory needs Pull requests read, coordinated requests also need Actions read. + description: Caller repository read token; issue-loop needs Issues read and team-memory needs Contents and Pull requests read. required: false default: ${{ github.token }} pull-request-number: description: Merged PR number for manually dispatched team-memory requests. required: false default: '' + source-repository: + description: Canonical source owner/repository for coordinated reconciliation; defaults to the caller repository. + required: false + default: '' + source-repositories: + description: Trusted coordinator configuration mapping allowed source owner/repository names to numeric repository IDs; never forward dispatched inputs here. + required: false + default: '{}' + source-github-token: + description: Independent source read token for coordinated requests (Contents, Actions and Pull requests read); cross-repository callers supply source-scoped access. + required: false + default: ${{ github.token }} source-run-id: - description: Verified source push run for the IssueLens coordinator; do not combine with pull-request-number. + description: Source default-branch push run for reconciliation; requires attempt and before/after, not pull-request-number. required: false default: '' source-run-attempt: description: Source push run attempt; required with source-run-id. required: false default: '' - source-artifact-id: - description: Immutable identity-only source artifact; required with source-run-id. + push-before: + description: Full nonzero ancestor SHA selecting the requested reconciliation range; not an attested original push boundary. + required: false + default: '' + push-after: + description: Full nonzero source run head SHA ending the requested reconciliation range. required: false default: '' azure-client-id: @@ -83,46 +99,23 @@ outputs: runs: using: composite steps: - - name: Validate coordinated team-memory source - id: source - if: >- - inputs.request-type == 'team-memory' && - (inputs.source-run-id != '' || inputs.source-run-attempt != '' || inputs.source-artifact-id != '') - shell: bash - env: - GITHUB_ACTION_PATH: ${{ github.action_path }} - GH_TOKEN: ${{ inputs.github-token }} - REQUEST_TYPE: ${{ inputs.request-type }} - SOURCE_RUN_ID: ${{ inputs.source-run-id }} - SOURCE_RUN_ATTEMPT: ${{ inputs.source-run-attempt }} - SOURCE_ARTIFACT_ID: ${{ inputs.source-artifact-id }} - DISPATCH_PR: ${{ inputs.pull-request-number }} - run: python3 -I "$GITHUB_ACTION_PATH/issuelens_action.py" validate-source - - - name: Download verified team-memory source - if: steps.source.outputs.automatic == 'true' - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - artifact-ids: ${{ steps.source.outputs.source-artifact-id }} - run-id: ${{ steps.source.outputs.source-run-id }} - repository: ${{ github.repository }} - github-token: ${{ inputs.github-token }} - path: ${{ runner.temp }}/issuelens-team-memory-source - digest-mismatch: error - - name: Prepare and validate request id: preflight shell: bash env: GITHUB_ACTION_PATH: ${{ github.action_path }} GH_TOKEN: ${{ inputs.github-token }} + SOURCE_GH_TOKEN: ${{ inputs.source-github-token }} + SOURCE_REPOSITORY: ${{ inputs.source-repository }} + SOURCE_REPOSITORIES: ${{ inputs.source-repositories }} REQUEST_TYPE: ${{ inputs.request-type }} TASK_INPUT: ${{ inputs.input }} ISSUE_NUMBER: ${{ inputs.issue-number }} DISPATCH_PR: ${{ inputs.pull-request-number }} SOURCE_RUN_ID: ${{ inputs.source-run-id }} SOURCE_RUN_ATTEMPT: ${{ inputs.source-run-attempt }} - SOURCE_ARTIFACT_ID: ${{ inputs.source-artifact-id }} + PUSH_BEFORE: ${{ inputs.push-before }} + PUSH_AFTER: ${{ inputs.push-after }} OUTPUT_MODE: ${{ inputs.output-mode }} SUMMARY_MODE: ${{ inputs.summary-mode }} run: python3 -I "$GITHUB_ACTION_PATH/issuelens_action.py" preflight diff --git a/.github/actions/issuelens/issuelens_action.py b/.github/actions/issuelens/issuelens_action.py index 93cabcf..1b50206 100644 --- a/.github/actions/issuelens/issuelens_action.py +++ b/.github/actions/issuelens/issuelens_action.py @@ -26,8 +26,8 @@ COORDINATOR_REPOSITORY = "microsoft/IssueLens" DISPATCH_WORKFLOW = ".github/workflows/team-memory-post-merge.yml" COORDINATOR_WORKFLOW = ".github/workflows/team-memory-coordinator.yml" -SOURCE_INPUTS = ("SOURCE_RUN_ID", "SOURCE_RUN_ATTEMPT", "SOURCE_ARTIFACT_ID") -MAX_SOURCE_BYTES = 64 * 1024 +SOURCE_INPUTS = ("SOURCE_RUN_ID", "SOURCE_RUN_ATTEMPT", "PUSH_BEFORE", "PUSH_AFTER") +SOURCE_REPOSITORY_PATTERN = r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}" class SkippedRequest(Exception): @@ -64,12 +64,12 @@ def full_sha(value): return value -def github_request(path, payload=None): +def github_request(path, payload=None, *, token=None): return urllib.request.Request( "https://api.github.com" + path, data=None if payload is None else json.dumps(payload).encode("utf-8"), headers={ - "Authorization": "Bearer " + os.environ["GH_TOKEN"], + "Authorization": "Bearer " + (os.environ["GH_TOKEN"] if token is None else token), "Accept": "application/vnd.github+json", "Content-Type": "application/json", "X-GitHub-Api-Version": "2022-11-28", @@ -77,8 +77,8 @@ def github_request(path, payload=None): ) -def github_read(path, payload=None): - request = github_request(path, payload) +def github_read(path, payload=None, *, token=None): + request = github_request(path, payload, token=token) with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response: data = response.read(4 * 1024 * 1024 + 1) require(len(data) <= 4 * 1024 * 1024, "GitHub response exceeds the preflight limit") @@ -117,9 +117,16 @@ def build_team_memory_request(metadata): def build_team_memory_batch_request(metadata): + range_context = ( + "This is an authorized reconciliation of a caller-selected commit range, not an attestation " + "of the original push boundary. push_before is a requested ancestor; the source run verifies " + "push_after, and GitHub comparison verifies the complete fast-forward inventory. " + if metadata.get("range_origin") == "authorized-reconciliation" else "" + ) task = ( "Reconcile durable wiki knowledge from the verified merged PR batch below. " "This request comes from the source repository's default-branch push workflow. " + + range_context + "Origin and supplied metadata are context, not independent authorization proof. " "Route this single wiki-maintenance job, including every PR and these constraints, to team-memory. " "Re-read the repository and every listed PR through bundled GitHub tools; verify the repository, " @@ -197,8 +204,8 @@ def team_memory_metadata(repository, project, event): } -def read_merged_pr(repository, project, number): - pull = github_read(f"/repos/{repository}/pulls/{number}") +def read_merged_pr(repository, project, number, *, token=None): + pull = github_read(f"/repos/{repository}/pulls/{number}", token=token) require(type(pull.get("number")) is int and pull["number"] == number and pull.get("merged") is True and pull.get("state") == "closed", "Selected pull request is not merged") @@ -237,30 +244,40 @@ def validate_push_event(event, reference, head_sha): return before, after, shas -def prepare_push_memory(repository, project, event, source_metadata=None): +def prepare_push_memory(repository, project, event): before, after, shas = validate_push_event( event, "refs/heads/" + project["default_branch"], - os.environ.get("GITHUB_SHA") if source_metadata is None else source_metadata["workflow_sha"], + os.environ.get("GITHUB_SHA"), ) - sha_set = set(shas) deadline = time.monotonic() + DISCOVERY_SECONDS require(time.monotonic() < deadline, "Push discovery exceeded its time budget") # GitHub includes comparison file diffs only on the first page. This page # verifies the trusted event inventory's count without downloading them. comparison = github_read(f"/repos/{repository}/compare/{before}...{after}?per_page=1&page=2") + validate_range_comparison(comparison, before, len(shas)) + sha_set = set(shas) + page = comparison.get("commits") + require(isinstance(page, list) and len(page) == (1 if len(shas) > 1 else 0) + and all(isinstance(item, dict) and item.get("sha") in sha_set for item in page), + "Comparison page does not match the push inventory") + metadata = team_memory_metadata(repository, project, event) + return prepare_memory_inventory(repository, project, before, after, shas, metadata, deadline) + + +def validate_range_comparison(comparison, before, count): require(isinstance(comparison.get("base_commit"), dict) and isinstance(comparison.get("merge_base_commit"), dict) and comparison["base_commit"].get("sha") == before and comparison["merge_base_commit"].get("sha") == before and comparison.get("status") == "ahead" and type(comparison.get("behind_by")) is int and comparison["behind_by"] == 0 - and type(comparison.get("ahead_by")) is int and comparison["ahead_by"] == len(shas) - and type(comparison.get("total_commits")) is int and comparison["total_commits"] == len(shas), + and type(comparison.get("ahead_by")) is int and comparison["ahead_by"] == count + and type(comparison.get("total_commits")) is int and comparison["total_commits"] == count, "Push range is not a complete fast-forward inventory; use manual PR dispatch") - page = comparison.get("commits") - require(isinstance(page, list) and len(page) == (1 if len(shas) > 1 else 0) - and all(isinstance(item, dict) and item.get("sha") in sha_set for item in page), - "Comparison page does not match the push inventory") + + +def prepare_memory_inventory(repository, project, before, after, shas, metadata, deadline, *, token=None): + sha_set = set(shas) owner, name = repository.split("/", 1) pulls = {} rest_merges = {} @@ -278,7 +295,7 @@ def prepare_push_memory(repository, project, event, source_metadata=None): "query": "query($owner:String!,$name:String!) { repository(owner:$owner,name:$name) { " "databaseId nameWithOwner defaultBranchRef { name target { oid } } " + selections + " } }", "variables": {"owner": owner, "name": name}, - }) + }, token=token) require(not response.get("errors") and isinstance(response.get("data"), dict), "Commit-to-PR metadata lookup failed") resolved = response["data"].get("repository") @@ -292,6 +309,8 @@ def prepare_push_memory(repository, project, event, source_metadata=None): target = resolved["defaultBranchRef"].get("target") require(isinstance(target, dict), "Current default-branch source is unavailable") source_tip_sha = full_sha(target.get("oid")) + require(metadata.get("source_tip_sha", source_tip_sha) == source_tip_sha, + "Source default branch changed during reconciliation discovery") for index, sha in enumerate(batch): commit = resolved.get(f"c{index}") require(isinstance(commit, dict) and commit.get("oid") == sha @@ -327,7 +346,7 @@ def prepare_push_memory(repository, project, event, source_metadata=None): require(len(rest_merges) < MAX_BATCH_PRS, "Push exceeds the PR metadata lookup limit; use manual PR dispatch") require(time.monotonic() < deadline, "Push discovery exceeded its time budget") - rest_merges[number] = read_merged_pr(repository, project, number) + rest_merges[number] = read_merged_pr(repository, project, number, token=token) require(time.monotonic() < deadline, "Push discovery exceeded its time budget") resolved_merge = rest_merges[number] require(resolved_merge["merged_at"] == pull.get("mergedAt"), @@ -350,7 +369,6 @@ def prepare_push_memory(repository, project, event, source_metadata=None): require(time.monotonic() < deadline, "Push discovery exceeded its time budget") if not pulls: raise SkippedRequest("no_merged_pull_requests") - metadata = dict(source_metadata) if source_metadata is not None else team_memory_metadata(repository, project, event) metadata.update( push_before=before, push_after=after, source_tip_sha=source_tip_sha, commit_count=len(shas), pull_requests=[pulls[number] for number in sorted(pulls)], @@ -364,9 +382,8 @@ def coordinator_workflow(repository, project): def require_coordinator(repository, event): - require(repository.lower() == COORDINATOR_REPOSITORY.lower() - and os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch", - "The coordinator currently accepts only IssueLens workflow dispatches") + require(os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch", + "Coordinated reconciliation requires a workflow dispatch") project = validate_workflow(repository, event) require(os.environ["GITHUB_WORKFLOW_REF"] == coordinator_workflow(repository, project), "Unexpected coordinator workflow") @@ -380,65 +397,45 @@ def source_identifiers(): positive(os.environ.get("DISPATCH_PR", "")) return None require(all(values) and not os.environ.get("DISPATCH_PR", ""), - "Supply all three source identifiers or one manual PR, not both") - return tuple(positive(value) for value in values) + "Supply run ID, attempt, before and after, or one manual PR, not both") + before, after = full_sha(values[2]), full_sha(values[3]) + require(before != after, "Reconciliation requires a nonempty commit range") + return positive(values[0]), positive(values[1]), before, after def verify_dispatch_source(repository, project, identifiers): - run_id, attempt, artifact_id = identifiers - source = github_read(f"/repos/{repository}/actions/runs/{run_id}/attempts/{attempt}") - require(source.get("id") == run_id and source.get("run_attempt") == attempt + run_id, attempt, _, after = identifiers + source = github_read(f"/repos/{repository}/actions/runs/{run_id}/attempts/{attempt}", + token=os.environ["SOURCE_GH_TOKEN"]) + require(type(source.get("id")) is int and source["id"] == run_id + and type(source.get("run_attempt")) is int and source["run_attempt"] == attempt and source.get("event") == "push" and source.get("path") == DISPATCH_WORKFLOW + and source.get("head_sha") == after and source.get("head_branch") == project["default_branch"] and isinstance(source.get("repository"), dict) + and type(source["repository"].get("id")) is int and source["repository"].get("id") == project["id"] and source["repository"].get("full_name", "").lower() == repository.lower() and isinstance(source.get("head_repository"), dict) + and type(source["head_repository"].get("id")) is int and source["head_repository"].get("id") == project["id"], - "Source run is not the trusted IssueLens default-branch push workflow") + "Source run does not match the allowed default-branch workflow and requested after SHA") head_sha = full_sha(source.get("head_sha")) actor, triggering_actor = source.get("actor"), source.get("triggering_actor") require(isinstance(actor, dict) and isinstance(triggering_actor, dict) and all(isinstance(item.get("login"), str) and re.fullmatch(r"[A-Za-z0-9-]+(?:\[bot\])?", item["login"]) for item in (actor, triggering_actor)), "Invalid source run actors") - artifact = github_read(f"/repos/{repository}/actions/artifacts/{artifact_id}") - origin = artifact.get("workflow_run") - require(artifact.get("id") == artifact_id and artifact.get("expired") is False - and artifact.get("name") == f"issuelens-team-memory-source-{attempt}" - and isinstance(artifact.get("digest"), str) - and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact["digest"]) - and type(artifact.get("size_in_bytes")) is int - and 0 < artifact["size_in_bytes"] <= MAX_SOURCE_BYTES - and isinstance(origin, dict) and origin.get("id") == run_id - and origin.get("repository_id") == project["id"] - and origin.get("head_repository_id") == project["id"] - and origin.get("head_branch") == project["default_branch"] - and origin.get("head_sha") == head_sha, - "Source artifact is expired, oversized, lacks integrity metadata, or belongs to a different run") return { "repository": repository, "repository_id": project["id"], "base_ref": project["default_branch"], "event_name": "push", "event_action": "push", "actor_login": actor["login"], "triggering_actor": triggering_actor["login"], "workflow_ref": repository + "/" + DISPATCH_WORKFLOW + "@refs/heads/" + project["default_branch"], "workflow_sha": head_sha, "run_id": run_id, "run_attempt": attempt, + "range_origin": "authorized-reconciliation", } -def source_event_path(): - return Path(os.environ["RUNNER_TEMP"]) / "issuelens-team-memory-source" / "source-event.json" - - -def read_source_event(): - with source_event_path().open("rb") as source_file: - content = source_file.read(MAX_SOURCE_BYTES + 1) - require(len(content) <= MAX_SOURCE_BYTES, "Source event exceeds 64 KiB") - snapshot = json.loads(content) - require(isinstance(snapshot, dict) and set(snapshot) == {"metadata", "event"}, - "Invalid source event artifact") - return snapshot - - def coordinator_metadata(repository): return { "coordinator_repository": repository, @@ -446,86 +443,98 @@ def coordinator_metadata(repository): "coordinator_workflow_sha": full_sha(os.environ["GITHUB_WORKFLOW_SHA"]), "coordinator_run_id": positive(os.environ["GITHUB_RUN_ID"]), "coordinator_run_attempt": positive(os.environ["GITHUB_RUN_ATTEMPT"]), - "required_wiki_repository": COORDINATOR_REPOSITORY, + "required_wiki_repository": repository, } -def prepare_coordinated_memory(repository, event): - identifiers = source_identifiers() - project = require_coordinator(repository, event) - require(identifiers is not None, "Coordinated push requires source identifiers") - metadata = verify_dispatch_source(repository, project, identifiers) - snapshot = read_source_event() - require(isinstance(snapshot["metadata"], dict) - and all(type(snapshot["metadata"].get(name)) is int - for name in ("repository_id", "run_id", "run_attempt")) - and snapshot["metadata"] == metadata, "Source artifact metadata does not match the verified run") - push = snapshot["event"] - require(isinstance(push, dict) - and set(push) == {"repository", "ref", "before", "after", "created", "deleted", - "forced", "commits", "head_commit"} - and push["repository"] == {"id": project["id"], "full_name": repository} - and type(push["repository"]["id"]) is int - and isinstance(push["commits"], list) - and all(isinstance(item, dict) and set(item) == {"id"} for item in push["commits"]) - and push["head_commit"] == {"id": metadata["workflow_sha"]}, - "Invalid identity-only push artifact") - return prepare_push_memory(repository, project, push, {**metadata, **coordinator_metadata(repository)}) - - -def team_memory_source_snapshot(repository, event): - require(type(event["repository"]["id"]) is int - and event["repository"]["full_name"].lower() == repository.lower(), "Event repository mismatch") - project = validate_workflow(repository, event) - require(type(project["id"]) is int and project["id"] > 0 and project["full_name"] == repository, - "Source repository must match its canonical authenticated identity") - require(os.environ["GITHUB_WORKFLOW_REF"] == repository + "/" + DISPATCH_WORKFLOW - + "@refs/heads/" + project["default_branch"], "Unexpected dispatch workflow") - before, after, shas = validate_push_event(event, os.environ["GITHUB_REF"], os.environ.get("GITHUB_SHA")) - require(os.environ["GITHUB_WORKFLOW_SHA"] == after, "Source workflow revision does not match the push") - return { - "metadata": team_memory_metadata(repository, project, event), - "event": { - "repository": {"id": project["id"], "full_name": repository}, "ref": event["ref"], - "before": before, "after": after, "created": False, "deleted": False, "forced": False, - "commits": [{"id": sha} for sha in shas], "head_commit": {"id": after}, - }, - } - - -def prepare_source(): - repository = os.environ["GITHUB_REPOSITORY"] - require(os.environ["GITHUB_EVENT_NAME"] == "push", "Only pushes may prepare a team-memory source") - require(os.environ["GH_TOKEN"].strip(), "Source read token must be non-empty") - event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8")) - snapshot = team_memory_source_snapshot(repository, event) - content = json.dumps(snapshot, separators=(",", ":")).encode("utf-8") - require(len(content) <= MAX_SOURCE_BYTES, "Source event exceeds 64 KiB") - path = source_event_path() - path.parent.mkdir(exist_ok=True) - path.write_bytes(content) - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: - output.write(f"source-event-path={path}\n") - print("Prepared identity-only team-memory source event") - - -def validate_source(): - require(os.environ["REQUEST_TYPE"] == "team-memory", "Only team-memory requests may download source artifacts") - repository = os.environ["GITHUB_REPOSITORY"] - event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8")) +def source_policy(coordinator): + repository = os.environ.get("SOURCE_REPOSITORY", "") or coordinator + require(re.fullmatch(SOURCE_REPOSITORY_PATTERN, repository) and ".." not in repository, + "Invalid source-repository") + content = os.environ.get("SOURCE_REPOSITORIES", "{}") + require(len(content.encode("utf-8")) <= 4096, "source-repositories exceeds 4 KiB") + try: + allowed = json.loads(content, object_pairs_hook=unique_object) + except json.JSONDecodeError: + raise ValueError("source-repositories must be a repository-to-ID JSON object") from None + require(isinstance(allowed, dict) and len(allowed) <= 100 + and all(re.fullmatch(SOURCE_REPOSITORY_PATTERN, name) and ".." not in name + and type(identifier) is int and identifier > 0 for name, identifier in allowed.items()), + "source-repositories must map repository names to positive numeric IDs") + require(len({name.lower() for name in allowed}) == len(allowed), "Duplicate source repository names") + allowed = {name.lower(): identifier for name, identifier in allowed.items()} + require(repository.lower() == coordinator.lower() or repository.lower() in allowed, + "Source repository is not allowed by the coordinator workflow") + return repository, allowed.get(repository.lower()) + + +def read_reconciliation_inventory(repository, before, after, deadline): + token = os.environ["SOURCE_GH_TOKEN"] + count = None + shas = [] + for page in range(1, MAX_PUSH_COMMITS // 100 + 1): + require(time.monotonic() < deadline, "Reconciliation discovery exceeded its time budget") + comparison = github_read(f"/repos/{repository}/compare/{before}...{after}?per_page=100&page={page}", token=token) + if count is None: + count = comparison.get("total_commits") + require(type(count) is int and 0 < count <= MAX_PUSH_COMMITS, + "Reconciliation range is empty or exceeds the 1000-commit limit") + validate_range_comparison(comparison, before, count) + commits = comparison.get("commits") + require(isinstance(commits, list) and len(commits) == min(100, count - len(shas)) + and all(isinstance(commit, dict) for commit in commits), + "Reconciliation commit pagination is incomplete") + shas.extend(full_sha(commit.get("sha")) for commit in commits) + require(len(set(shas)) == len(shas) and before not in shas, + "Reconciliation inventory has duplicate or invalid identities") + if len(shas) == count: + require(after in shas, "Reconciliation inventory does not contain the source run head") + return shas + raise ValueError("Reconciliation commit pagination is incomplete") + + +def prepare_coordinated_memory(coordinator, event): identifiers = source_identifiers() - project = require_coordinator(repository, event) - if identifiers is not None: - verify_dispatch_source(repository, project, identifiers) - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: - output.write(f"automatic={'true' if identifiers is not None else 'false'}\n") - if identifiers is not None: - output.write(f"source-run-id={identifiers[0]}\nsource-artifact-id={identifiers[2]}\n") - print("Validated queued team-memory source" if identifiers is not None else "Validated manual PR selection") + repository, expected_id = source_policy(coordinator) + require(re.fullmatch(r"[\x21-\x7e]{1,4096}", os.environ.get("SOURCE_GH_TOKEN", "")), + "source-github-token must be non-empty printable ASCII without whitespace (at most 4 KiB)") + deadline = time.monotonic() + DISCOVERY_SECONDS + central = require_coordinator(coordinator, event) + project = central if repository.lower() == coordinator.lower() else github_read( + f"/repos/{repository}", token=os.environ["SOURCE_GH_TOKEN"]) + require(type(project.get("id")) is int and project["id"] > 0 + and isinstance(project.get("full_name"), str) and project["full_name"] == repository + and (expected_id is None or project["id"] == expected_id), + "Source repository canonical identity does not match coordinator policy") + require(repository.lower() == coordinator.lower() + or project.get("visibility") == central.get("visibility") == "public", + "Cross-repository reconciliation currently requires public source and coordinator repositories") + metadata = coordinator_metadata(coordinator) + if identifiers is None: + merged = read_merged_pr(repository, project, positive(os.environ["DISPATCH_PR"]), token=os.environ["SOURCE_GH_TOKEN"]) + metadata = {**team_memory_metadata(repository, project, event), **merged, **metadata} + return {"metadata": metadata, "request": build_team_memory_request(metadata)} + metadata = {**verify_dispatch_source(repository, project, identifiers), **metadata} + before, after = identifiers[2:] + tip = github_read(f"/repos/{repository}/branches/{urllib.parse.quote(project['default_branch'], safe='')}", + token=os.environ["SOURCE_GH_TOKEN"]) + require(tip.get("name") == project["default_branch"] and isinstance(tip.get("commit"), dict), + "Source default branch identity mismatch") + tip_sha = full_sha(tip["commit"].get("sha")) + if after != tip_sha: + ancestry = github_read(f"/repos/{repository}/compare/{after}...{tip_sha}?per_page=1&page=2", + token=os.environ["SOURCE_GH_TOKEN"]) + count = ancestry.get("total_commits") + require(type(count) is int and count > 0, "Source run head is not an ancestor of the current default branch") + validate_range_comparison(ancestry, after, count) + metadata["source_tip_sha"] = tip_sha + shas = read_reconciliation_inventory(repository, before, after, deadline) + return prepare_memory_inventory(repository, project, before, after, shas, metadata, deadline, + token=os.environ["SOURCE_GH_TOKEN"]) def prepare_team_memory(repository, event): - if any(os.environ.get(name, "") for name in SOURCE_INPUTS): + if any(os.environ.get(name, "") for name in (*SOURCE_INPUTS, "SOURCE_REPOSITORY")): return prepare_coordinated_memory(repository, event) event_name = os.environ["GITHUB_EVENT_NAME"] require(event_name in {"push", "pull_request_target", "workflow_dispatch"}, "Unsupported event") @@ -617,7 +626,8 @@ def preflight(): display_options() request_type = os.environ.get("REQUEST_TYPE", "") require(request_type in REQUEST_TYPES, "Choose request-type issue-loop, team-memory, or task") - require(request_type == "team-memory" or not any(os.environ.get(name, "") for name in SOURCE_INPUTS), + require(request_type == "team-memory" + or not any(os.environ.get(name, "") for name in (*SOURCE_INPUTS, "SOURCE_REPOSITORY")), "Source identifiers are supported only for team-memory coordinator requests") if request_type != "task": require(not os.environ.get("TASK_INPUT", "").strip(), "The input field is supported only for request-type task") @@ -646,7 +656,7 @@ def preflight(): def unique_object(pairs): result = {} for key, value in pairs: - require(key not in result, "Agent result contains duplicate JSON keys") + require(key not in result, "Duplicate JSON keys are not allowed") result[key] = value return result @@ -856,10 +866,6 @@ def run(command): preflight() elif command == "submit": submit() - elif command == "prepare-source": - prepare_source() - elif command == "validate-source": - validate_source() else: raise ValueError("Unsupported action command") except ValueError as error: @@ -875,5 +881,5 @@ def run(command): if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=("preflight", "submit", "prepare-source", "validate-source")) + parser.add_argument("command", choices=("preflight", "submit")) run(parser.parse_args().command) diff --git a/.github/actions/queue-team-memory/README.md b/.github/actions/queue-team-memory/README.md index 3f72556..21b0e87 100644 --- a/.github/actions/queue-team-memory/README.md +++ b/.github/actions/queue-team-memory/README.md @@ -38,28 +38,27 @@ permissions, or grant the receiving job authorization. ## Team-Memory Usage -The source workflow owns request-specific evidence preparation and artifact -upload **before** this action. In IssueLens, -[team-memory-post-merge.yml](../../workflows/team-memory-post-merge.yml) loads -trusted code at `github.workflow_sha` with credentials not persisted, calls the -request-owned `issuelens_action.py prepare-source` helper with a source read -token, and uploads its sanitized identity-only push artifact with a pinned -uploader. The artifact retains original `before`, `after`, and all commit IDs; -the source run API alone cannot recover the original push range. The -[invocation action](../issuelens/README.md#issuelens-coordinator-pilot) owns the -artifact schema and the receiving validation/download contract. +In IssueLens, [team-memory-post-merge.yml](../../workflows/team-memory-post-merge.yml) +calls this action with only repository, run/attempt, and requested before/after +SHAs. No checkout, preparation script, or artifact is needed. The +[invocation action](../issuelens/README.md#issuelens-coordinator-pilot) validates +allowed source identity, the authoritative run and current branch ancestry, +then retrieves the complete requested commit range centrally. +The request authorizes reconciliation; it does not attest original `before`, +which the source run API cannot recover. A pinned remote dispatch action needs no caller checkout. Replace `FULL_COMMIT_SHA` with a reviewed full commit SHA; this is not a published version. -After preparation/upload, the IssueLens pilot supplies its unchanged three-ID -payload to its same-repository coordinator: +The IssueLens pilot supplies its five-field reconciliation request to its +same-repository coordinator: ```yaml - name: Queue team-memory request uses: microsoft/IssueLens/.github/actions/queue-team-memory@FULL_COMMIT_SHA with: coordinator-workflow: team-memory-coordinator.yml - workflow-inputs: '{"source_run_id":"${{ github.run_id }}", "source_run_attempt":"${{ github.run_attempt }}", "source_artifact_id":"${{ steps.artifact.outputs.artifact-id }}"}' + coordinator-ref: main + workflow-inputs: '{"source_repository":"${{ github.repository }}", "source_run_id":"${{ github.run_id }}", "source_run_attempt":"${{ github.run_attempt }}", "push_before":"${{ github.event.before }}", "push_after":"${{ github.sha }}"}' ``` For a source using `develop` and a central coordinator using `main`, set the @@ -73,15 +72,16 @@ target ref independently and supply the receiving coordinator's own payload: coordinator-repository: microsoft/vscode-java-pack coordinator-workflow: team-memory-coordinator.yml coordinator-ref: main - workflow-inputs: '{"source_repository":"${{ github.repository }}", "source_run_id":"${{ github.run_id }}", "source_run_attempt":"${{ github.run_attempt }}", "source_artifact_id":"${{ steps.artifact.outputs.artifact-id }}"}' + workflow-inputs: '{"source_repository":"${{ github.repository }}", "source_run_id":"${{ github.run_id }}", "source_run_attempt":"${{ github.run_attempt }}", "push_before":"${{ github.event.before }}", "push_after":"${{ github.sha }}"}' ``` The second example is a later consumer migration contract, not enablement or -a change to Java Pack. The caller authenticates/prepares its source separately -from this target-only action. Receivers own source/workflow allowlists, -run/attempt/artifact provenance and digest checks, privacy/wiki scope, the -central queue, and final maintenance validation. Arbitrary dispatch input -claims are not trusted provenance or write authorization. +a change to Java Pack. Configure the source workflow to reject created/deleted/ +forced pushes and mismatched workflow/head SHAs. The receiving coordinator owns +the trusted source-name/ID allowlist, independent source-read credentials, +run/head and complete-range validation, privacy/wiki scope, queue, and final +maintenance validation. Never forward dispatch inputs into the source allowlist. +Arbitrary input claims are not trusted provenance or new write authorization. ## Transport and Outcomes diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c1daf08..d12a7d2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -318,29 +318,38 @@ trusted event metadata. Per-issue concurrency allows different issues to run independently while coalescing bursts for the same issue. Team-memory postmerge orchestration in this repository uses two opt-in workflows: -`.github/workflows/team-memory-post-merge.yml` uses the request-owned -`issuelens_action.py prepare-source` helper to validate the default-branch push, -then a pinned uploader preserves its bounded identity-only artifact for seven -days. Original before/after and all commit IDs remain explicit; the source run -API alone does not establish the original before range. It then calls the -standalone generic `.github/actions/queue-team-memory` action to dispatch -`.github/workflows/team-memory-coordinator.yml` with its unchanged three-ID -payload in caller-supplied `workflow-inputs` JSON. The dispatcher owns only +`.github/workflows/team-memory-post-merge.yml` calls the pinned standalone +generic `.github/actions/queue-team-memory` action with source repository, +run/attempt, and requested before/after SHAs in `workflow-inputs` JSON. +It needs no checkout, preparation script, or artifact. The source workflow +rejects unsafe push flags and mismatched workflow/head SHAs. +The dispatcher sends `.github/workflows/team-memory-coordinator.yml` one +bounded reconciliation request and owns only validated target dispatch: its own `dispatch.py`, no sibling action imports, source preparation, artifacts, agent calls, wiki policy, or job authorization. Its `coordinator-repository` defaults to the caller repository, `coordinator-workflow` requires a YAML basename, and `coordinator-ref` defaults to the calling branch, independent of any source default branch. Its target -token requires Contents read and Actions write; source preparation independently -uses a source read token. Target inputs and credentials grant no new access. +token requires Contents read and Actions write. The invocation action separately +uses `source-github-token` for source Contents, Actions and Pull requests reads. +Target inputs and credentials grant no new access. The central coordinator owns the actual concurrency queue and only calls `.github/actions/issuelens` after trusted sparse checkout. The invocation action -owns source/workflow allowlists, verification before pinned digest-checked -download, revalidation/discovery before OIDC login, policy/privacy, and final -outcomes. Its IssueLens coordinated adapter stays pilot-scoped; direct external -action callers retain their request behavior. Dispatch no longer prepares or -uploads artifacts or infers source fields. Java Pack's older immutable pin stays +owns source/workflow validation, current default-branch ancestry, full paginated +range/PR discovery before OIDC login, policy/privacy, and final outcomes. +The trusted coordinator workflow may configure `source-repositories`, a bounded +canonical-name-to-numeric-ID JSON map; never populate it from dispatch inputs. +Cross-repository sources and coordinators must both be public. +The IssueLens workflow leaves that map empty, permitting only its own source. +Direct external action callers retain their request behavior. +No action prepares or downloads a source artifact. Java Pack's older immutable pin stays unchanged and needs a separately authorized consumer migration. +The coordinated range is authorized reconciliation, not original-event proof: +the run API verifies after/head but cannot attest original before or push flags. +Preserve this explicit limitation in the request. Do not manufacture a push +event, substitute the coordinator head, or infer no-change from missing pages. +At most 1,000 complete unique commit IDs are retrieved in pages of 100; +divergent ranges, incomplete inventories, and ref races fail before login. Explicit single-PR manual requests use the coordinator instead of source IDs. The shared composite action in `.github/actions/issuelens` owns preflight, pinned Azure OIDC login, and submission through a standalone @@ -377,8 +386,8 @@ retaining its per-PR response and any confirmed wiki publication, never implying that no write occurred. The batch schema remains caller-owned. Ambiguous agent submissions and workflow dispatches are not retried automatically. A timeout or cancellation does not prove the hosted invocation -stopped; the queue is not a distributed runtime lock. Monitor overflow, -expired artifacts, and failed/canceled runs. Git provides knowledge, history, and conflict +stopped; the queue is not a distributed runtime lock. Monitor overflow +and failed/canceled runs. Git provides knowledge, history, and conflict detection, not a durable job queue or guaranteed exactly-once delivery. Local tests do not establish live OIDC federation, hosted writer dispatch, or publication. diff --git a/.github/workflows/team-memory-coordinator.yml b/.github/workflows/team-memory-coordinator.yml index 25f1c67..f408327 100644 --- a/.github/workflows/team-memory-coordinator.yml +++ b/.github/workflows/team-memory-coordinator.yml @@ -3,6 +3,10 @@ name: Coordinate IssueLens team memory (opt-in) on: workflow_dispatch: inputs: + source_repository: + description: Source repository (defaults to this repository; other sources require trusted coordinator configuration) + required: false + type: string source_run_id: description: Source push workflow run ID (automatic dispatch only) required: false @@ -11,8 +15,12 @@ on: description: Source workflow attempt (required with source_run_id) required: false type: string - source_artifact_id: - description: Immutable source event artifact ID (required with source_run_id) + push_before: + description: Requested reconciliation ancestor SHA, not an attested original push boundary + required: false + type: string + push_after: + description: Source push run head SHA (required with source_run_id) required: false type: string pull_request_number: @@ -54,9 +62,11 @@ jobs: uses: ./.github/actions/issuelens with: request-type: team-memory + source-repository: ${{ inputs.source_repository }} source-run-id: ${{ inputs.source_run_id }} source-run-attempt: ${{ inputs.source_run_attempt }} - source-artifact-id: ${{ inputs.source_artifact_id }} + push-before: ${{ inputs.push_before }} + push-after: ${{ inputs.push_after }} pull-request-number: ${{ inputs.pull_request_number }} azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/.github/workflows/team-memory-post-merge.yml b/.github/workflows/team-memory-post-merge.yml index bcf1082..ba4a319 100644 --- a/.github/workflows/team-memory-post-merge.yml +++ b/.github/workflows/team-memory-post-merge.yml @@ -12,44 +12,25 @@ jobs: vars.ISSUELENS_TEAM_MEMORY_ENABLED == 'true' && github.repository == 'microsoft/IssueLens' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && - github.event_name == 'push' + github.event_name == 'push' && + github.workflow_sha == github.sha && + github.event.created == false && + github.event.deleted == false && + github.event.forced == false runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read actions: write steps: - - name: Load action from trusted workflow revision - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.workflow_sha }} - persist-credentials: false - sparse-checkout: | - /.github/actions/issuelens/ - /.github/actions/queue-team-memory/ - sparse-checkout-cone-mode: false - - - name: Prepare identity-only team-memory source - id: source - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: python3 -I .github/actions/issuelens/issuelens_action.py prepare-source - - - name: Preserve source event for the coordinator - id: artifact - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: issuelens-team-memory-source-${{ github.run_attempt }} - path: ${{ steps.source.outputs.source-event-path }} - if-no-files-found: error - retention-days: 7 - - name: Queue team-memory request - uses: ./.github/actions/queue-team-memory + uses: microsoft/IssueLens/.github/actions/queue-team-memory@296350903a578f3bcd847b34ce85b51e90b4b919 with: coordinator-workflow: team-memory-coordinator.yml + coordinator-ref: main workflow-inputs: >- - {"source_run_id":"${{ github.run_id }}", + {"source_repository":"${{ github.repository }}", + "source_run_id":"${{ github.run_id }}", "source_run_attempt":"${{ github.run_attempt }}", - "source_artifact_id":"${{ steps.artifact.outputs.artifact-id }}"} + "push_before":"${{ github.event.before }}", + "push_after":"${{ github.sha }}"} diff --git a/docs/guide.md b/docs/guide.md index a734f9b..ce5dfbc 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -179,12 +179,11 @@ contracts do not establish live hosted sub-agent dispatch or deployment. that land fork PRs, so the trusted base workflow has endpoint credentials without a `pull_request_target` policy exception. Set the `push.branches` filter to the source repository's default branch (`main` in this repository). - IssueLens's own push workflow loads the standalone `queue-team-memory` - action and separately the request-owned `issuelens` preparation helper; - the coordinator loads and calls only `issuelens`. - Both use `github.workflow_sha` - with credentials not persisted. Neither path checks out or executes PR-head - code. Protect workflow and action changes as privileged code. + IssueLens's own push workflow calls only an immutable remote + `queue-team-memory` action, with no checkout or preparation script. + The coordinator loads and calls only `issuelens`, using `github.workflow_sha` + with credentials not persisted. Neither path executes PR-head code. + Protect workflow and action changes as privileged code. 2. Reuse the issue-loop Actions secrets: `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`, `ISSUELENS_AGENT_URL` (the complete Foundry invocations endpoint), and `ISSUELENS_AGENT_SCOPE`. The old skeleton's @@ -223,25 +222,30 @@ across separate pushes, so an ordinary one-PR merge still usually produces one r [push-triggered workflow](../.github/workflows/team-memory-post-merge.yml) no longer calls Foundry. It calls the reusable [queue-team-memory action](../.github/actions/queue-team-memory/README.md), which -is a standalone generic target dispatcher. The source workflow first calls the -request-owned `issuelens_action.py prepare-source` helper with its source read -token and a pinned artifact uploader. It validates the push and stores a bounded -identity-only source artifact for seven days, retaining original before/after -and all commit IDs. The run API alone cannot recover original before. -The separate dispatcher uses only a target token and caller-supplied JSON inputs +is a standalone generic target dispatcher. The source workflow rejects unsafe +push flags and mismatched workflow/head SHAs, then supplies only +`source_repository`, `source_run_id`, `source_run_attempt`, `push_before`, and +`push_after` as string values. There is no source checkout, script, artifact +upload, or download. The dispatcher uses only a target token and these JSON inputs to dispatch the -[coordinator workflow](../.github/workflows/team-memory-coordinator.yml) with -only the source run, attempt, and immutable artifact IDs. No source code, -commit messages, issue/PR bodies, agent output, or credentials are uploaded. +[coordinator workflow](../.github/workflows/team-memory-coordinator.yml). +No source code, commit messages, issue/PR bodies, agent output, or credentials +are included. The coordinator keeps the queue but only invokes the shared `issuelens` action -after trusted sparse checkout. That invocation action verifies and downloads -the source artifact internally before Azure login. It checks the originating -dispatcher, source repository/default -branch, run attempt, head SHA, and artifact provenance before downloading. -Missing digests, digest mismatches, expired or oversized artifacts, and mismatched source -identities fail before Azure login. The shared action revalidates the source -and complete push inventory before invoking the agent; it never substitutes -the coordinator's newer head for the original push range. +after trusted sparse checkout. Before Azure login, that action authenticates +the allowed source and exact default-branch push workflow/run/attempt, matches +`push_after` to its head, verifies current default-branch ancestry, and retrieves +the full fast-forward requested range in pages of 100 commits (at most 1,000). +It then discovers all eligible merged PRs, rejecting incomplete inventories, +duplicates, identity mismatches, divergent ranges, or a ref race. Source and +coordinator heads remain distinct; a newer coordinator head never replaces +the requested range. + +**Range trust:** this authorizes reconciliation of a selected ancestor range, +not attestation of the original push event. The source run API cannot prove +original `before` or push flags. `push_before` is explicitly a requested +boundary, and this limitation is included in the agent task. No original-event +snapshot, artifact digest, or seven-day lifetime is claimed. Both automatic requests and manual PR updates execute in the coordinator repository under `issuelens-team-memory-wiki-microsoft-IssueLens`, using @@ -261,7 +265,7 @@ The wrapper executes in the source workflow; only the dispatched coordinator workflow owns the central concurrency queue. Java tooling rollout is separate: those repositories share the `microsoft/vscode-java-pack` wiki and will need their own central queue and -authenticated source adapter. The existing direct-action consumers continue +trusted source configuration. The existing direct-action consumers continue working unchanged, but are not serialized by this pilot. The standalone dispatch action supports `coordinator-repository` (default calling repository), required `coordinator-workflow` (YAML basename), @@ -270,13 +274,21 @@ JSON. It independently validates target identities using `dispatch-token` and never prepares artifacts or synthesizes source identity fields. The coordinator branch is independent of the source default branch. See the [queue action contract](../.github/actions/queue-team-memory/README.md) -for the exact input and artifact schemas. Receivers still own source allowlists, -artifact/range revalidation, privacy, wiki scope, and maintenance outcomes; +for the exact transport and reconciliation request contracts. +Other coordinators can configure a reviewed `source-repositories` JSON map of +canonical source names to immutable numeric IDs, with independent +`source-github-token` Contents, Actions, and Pull requests read access. +Never forward dispatched inputs into that map. Cross-repository sources and +coordinators must both be public; the IssueLens workflow's empty map continues +to accept only its own source. Coordinated receipts must name the coordinator's +wiki, verified against source policy rather than overriding it. +Receivers still own source allowlists, range validation, privacy, wiki scope, and maintenance outcomes; this transport interface does not enable other workflows or provide credentials. -The IssueLens source workflow explicitly supplies its unchanged three-ID payload. -This is a deliberate dispatch-action interface change: source preparation/upload -and payload construction are now caller-owned. Java Pack remains pinned to the -older revision and needs a separate migration; this refactor does not alter it. +This deliberately replaces the coordinated artifact input with before/after +inputs and removes the obsolete preparation/download entrypoints; the generic +dispatch interface is unchanged. Java Pack remains pinned to the older revision +and needs a separate migration to the range request/shared invocation path; +this refactor does not alter it. Only PRs merged into the current default branch are accepted. Manual **Run workflow** now uses the coordinator, with a positive `pull_request_number` diff --git a/github_app_mcp/README.md b/github_app_mcp/README.md index 83bdae7..bb34ce6 100644 --- a/github_app_mcp/README.md +++ b/github_app_mcp/README.md @@ -337,22 +337,26 @@ read-only skill never writes or delegates ordinary reads to the writer. Git provides knowledge, history, and conflict detection, not a durable job queue, reconciliation service, external scheduler, or guaranteed exactly-once delivery. The optional [post-merge dispatcher](../.github/workflows/team-memory-post-merge.yml) -uses request-owned code from the -[invocation action](../.github/actions/issuelens/README.md) to validate the push -and a pinned uploader to preserve its identity-only before/after/commit inventory, -then uses the [standalone dispatch action](../.github/actions/queue-team-memory/README.md) -only to validate the target and dispatch caller-supplied workflow inputs to the +uses only the pinned [standalone dispatch action](../.github/actions/queue-team-memory/README.md) +to validate the target and dispatch source repository, run/attempt, and requested +before/after SHAs to the [queued coordinator](../.github/workflows/team-memory-coordinator.yml). The dispatcher does not authenticate to Azure or invoke the agent. Only the coordinator performs Azure OIDC login and agent submission through the [shared IssueLens action](../.github/actions/issuelens/README.md) with `request-type: team-memory`. The central workflow owns the queue and only calls the action (plus trusted local checkout); the invocation action internally -verifies source run/artifact provenance, performs pinned digest-checked download, -and revalidates the original complete push inventory before Azure login. +authenticates the allowed source, verifies its run/head and current default-branch +ancestry, and retrieves the complete bounded commit range through paginated +GitHub reads before Azure login. There is no source script, checkout, upload, or +download. A trusted coordinator source-name/ID map permits public cross-repository +sources with independent source-read credentials; the IssueLens pilot leaves it +empty, accepting only its own source. The generic dispatcher is self-contained and never imports invocation scripts, -prepares job evidence, or grants job authorization. The artifact's original -before range cannot be reconstructed from the run API alone. +prepares job evidence, or grants job authorization. This is authorized range +reconciliation, not attestation of an original push boundary: the run API cannot +recover original before or push flags. The request preserves this limitation +and keeps source and coordinator revisions distinct. Automatic push requests and manual PR requests share the coordinator's queue. This pilot accepts only `microsoft/IssueLens` sources and its configured wiki; external direct-action consumers remain supported but are not queued by it. diff --git a/tests/test_issue_triage_workflow.py b/tests/test_issue_triage_workflow.py index 550ef33..eeb7dcc 100644 --- a/tests/test_issue_triage_workflow.py +++ b/tests/test_issue_triage_workflow.py @@ -29,9 +29,7 @@ def test_preflight_rejects_pr_and_bot_comments_before_login(self): self.assertIn("github.event.comment.user.type == 'User'", gate) self.assertIn("github.event.repository.default_branch", gate) metadata = yaml.load((action_tests.ACTION_DIR / "action.yml").read_text(encoding="utf-8"), Loader=yaml.BaseLoader) - verify, download, preflight, login, submit = metadata["runs"]["steps"] - self.assertIn("inputs.request-type == 'team-memory'", verify["if"]) - self.assertEqual(download["if"], "steps.source.outputs.automatic == 'true'") + preflight, login, submit = metadata["runs"]["steps"] self.assertEqual(preflight["id"], "preflight") for step in (login, submit): self.assertEqual(step["if"], "steps.preflight.outputs.eligible == 'true'") diff --git a/tests/test_team_memory_coordinator.py b/tests/test_team_memory_coordinator.py index 2521685..ac3588a 100644 --- a/tests/test_team_memory_coordinator.py +++ b/tests/test_team_memory_coordinator.py @@ -1,7 +1,9 @@ import copy import json +import os import pathlib import unittest +from unittest.mock import patch import test_team_memory_workflow as memory_tests @@ -20,47 +22,33 @@ class TeamMemoryCoordinatorTests(unittest.TestCase): def setUp(self): memory_tests.TeamMemoryActionTests.setUp(self) self.repository = "microsoft/IssueLens" - self.project["full_name"] = self.repository + self.project.update(full_name=self.repository, visibility="public") self.before, self.after, self.tip = "d" * 40, "e" * 40, "f" * 40 self.environment.update( GITHUB_REPOSITORY=self.repository, GITHUB_EVENT_NAME="workflow_dispatch", GITHUB_SHA=self.tip, GITHUB_RUN_ID="999", GITHUB_WORKFLOW_REF=self.repository + "/" + action.COORDINATOR_WORKFLOW + "@refs/heads/main", - SOURCE_RUN_ID="123456", SOURCE_RUN_ATTEMPT="2", SOURCE_ARTIFACT_ID="456", - DISPATCH_PR="", + SOURCE_REPOSITORY=self.repository, SOURCE_REPOSITORIES="{}", + SOURCE_GH_TOKEN="fake-source-token", SOURCE_RUN_ID="123456", SOURCE_RUN_ATTEMPT="2", + PUSH_BEFORE=self.before, PUSH_AFTER=self.after, DISPATCH_PR="", ) self.event = {"repository": self.project} + self.source_project = self.project self.source_run = { "id": 123456, "run_attempt": 2, "event": "push", "path": action.DISPATCH_WORKFLOW, "head_branch": "main", "head_sha": self.after, "repository": self.project, "head_repository": self.project, "actor": {"login": "maintainer"}, "triggering_actor": {"login": "rerunner"}, } - self.artifact = { - "id": 456, "name": "issuelens-team-memory-source-2", "expired": False, "size_in_bytes": 2048, - "digest": "sha256:" + "1" * 64, - "workflow_run": { - "id": 123456, "repository_id": 100, "head_repository_id": 100, - "head_branch": "main", "head_sha": self.after, - }, - } - self.push = { - "repository": {"id": 100, "full_name": self.repository}, "ref": "refs/heads/main", - "before": self.before, "after": self.after, "created": False, "deleted": False, "forced": False, - "commits": [{"id": self.merge_sha}, {"id": self.after}], "head_commit": {"id": self.after}, - } - self.source_metadata = { - "repository": self.repository, "repository_id": 100, "base_ref": "main", - "event_name": "push", "event_action": "push", - "actor_login": "maintainer", "triggering_actor": "rerunner", - "workflow_ref": self.repository + "/" + action.DISPATCH_WORKFLOW + "@refs/heads/main", - "workflow_sha": self.after, "run_id": 123456, "run_attempt": 2, - } - self.snapshot = {"metadata": self.source_metadata, "event": self.push} + self.branch = {"name": "main", "commit": {"sha": self.tip}} self.comparison = { "base_commit": {"sha": self.before}, "merge_base_commit": {"sha": self.before}, "status": "ahead", "ahead_by": 2, "behind_by": 0, "total_commits": 2, - "commits": [{"sha": self.after}], + "commits": [{"sha": self.merge_sha}, {"sha": self.after}], + } + self.ancestry = { + "base_commit": {"sha": self.after}, "merge_base_commit": {"sha": self.after}, + "status": "ahead", "ahead_by": 1, "behind_by": 0, "total_commits": 1, } node = { "number": 27, "state": "MERGED", "merged": True, "mergedAt": self.pull["merged_at"], @@ -80,261 +68,263 @@ def responses(self, *values): return [Response(json.dumps(value).encode()) for value in values] def source_responses(self): - return self.responses(self.project, self.source_run, self.artifact) - - def write_source(self, content=None): - path = self.directory / "issuelens-team-memory-source" / "source-event.json" - path.parent.mkdir(exist_ok=True) - path.write_bytes(json.dumps(self.snapshot).encode() if content is None else content) - return path - - def select_dispatcher(self): - self.environment.update( - GITHUB_EVENT_NAME="push", GITHUB_SHA=self.after, GITHUB_WORKFLOW_SHA=self.after, - GITHUB_WORKFLOW_REF=self.source_metadata["workflow_ref"], GITHUB_RUN_ID="123456", - ) - self.environment.pop("SOURCE_RUN_ID") - self.environment.pop("SOURCE_RUN_ATTEMPT") - self.event = copy.deepcopy(self.push) - - def test_dispatcher_preserves_only_identities_and_never_invokes_foundry(self): - self.select_dispatcher() - self.event["commits"][0]["message"] = "UNTRUSTED_COMMIT_TEXT" - self.event["head_commit"]["message"] = "UNTRUSTED_HEAD_TEXT" - self.event["repository"]["description"] = "UNTRUSTED_REPOSITORY_TEXT" - self.execute("prepare-source", self.responses(self.project)) - path = pathlib.Path(self.action_outputs()["source-event-path"]) - snapshot = json.loads(path.read_bytes()) - self.assertEqual(snapshot, self.snapshot) - self.assertNotIn("UNTRUSTED", path.read_text()) - self.assertNotIn("fake-repository-token", path.read_text()) - self.assertEqual(path.name, "source-event.json") - self.assertEqual(self.opener.open.call_count, 1) - self.token.assert_not_called() - - def test_source_preparation_rejects_mismatched_repositories_workflows_and_unsafe_pushes(self): - self.select_dispatcher() - original_environment, original_event = self.environment.copy(), copy.deepcopy(self.event) - for change in ("repository", "workflow", "branch", "workflow_sha", "forced", "truncated"): - with self.subTest(change=change): - self.environment, self.event = original_environment.copy(), copy.deepcopy(original_event) - if change == "repository": - self.environment["GITHUB_REPOSITORY"] = "microsoft/vscode-java-pack" - elif change == "workflow": - self.environment["GITHUB_WORKFLOW_REF"] = self.repository + "/.github/workflows/untrusted.yml@refs/heads/main" - elif change == "branch": - self.environment["GITHUB_REF"] = "refs/heads/untrusted" - elif change == "workflow_sha": - self.environment["GITHUB_WORKFLOW_SHA"] = "b" * 40 - elif change == "forced": - self.event["forced"] = True - else: - self.event["commits"] = [] - with self.assertRaises(SystemExit): - self.execute("prepare-source", self.responses(self.project)) - self.assertFalse((self.directory / "output.txt").exists()) - self.assertFalse((self.directory / "issuelens-team-memory-source").exists()) - self.token.assert_not_called() - - def test_maximum_commit_inventory_fits_the_identity_artifact_budget(self): - self.select_dispatcher() - commits = [{"id": f"{index:040x}"} for index in range(1, action.MAX_PUSH_COMMITS)] - self.event["commits"] = commits + [{"id": self.after}] - self.execute("prepare-source", self.responses(self.project)) - content = pathlib.Path(self.action_outputs()["source-event-path"]).read_bytes() - self.assertLess(len(content), action.MAX_SOURCE_BYTES) - self.assertEqual(len(json.loads(content)["event"]["commits"]), action.MAX_PUSH_COMMITS) - pathlib.Path(self.action_outputs()["source-event-path"]).unlink() - (self.directory / "output.txt").unlink() - self.event["commits"].append({"id": f"{action.MAX_PUSH_COMMITS:040x}"}) - with self.assertRaisesRegex(SystemExit, "inventory"): - self.execute("prepare-source", self.responses(self.project)) - self.assertFalse((self.directory / "output.txt").exists()) - - def test_request_owned_source_supports_develop_and_exact_unchanged_snapshot(self): - self.select_dispatcher() - repository = "example/gradle" - self.project.update(full_name=repository, default_branch="develop") - self.environment.update( - GITHUB_REPOSITORY=repository, GITHUB_REF="refs/heads/develop", - GITHUB_WORKFLOW_REF=repository + "/" + action.DISPATCH_WORKFLOW + "@refs/heads/develop", - ) - self.event.update(repository={"id": 100, "full_name": repository}, ref="refs/heads/develop") - self.snapshot["metadata"].update(repository=repository, base_ref="develop", - workflow_ref=self.environment["GITHUB_WORKFLOW_REF"]) - self.snapshot["event"].update(repository={"id": 100, "full_name": repository}, ref="refs/heads/develop") - self.execute("prepare-source", self.responses(self.project)) - path = pathlib.Path(self.action_outputs()["source-event-path"]) - self.assertEqual(json.loads(path.read_bytes()), self.snapshot) - self.assertNotIn("fake-repository-token", path.read_text()) - self.assertNotIn("coordinator", path.read_text()) - self.assertEqual([call.args[0].get_method() for call in self.opener.open.call_args_list], ["GET"]) - self.token.assert_not_called() + values = [self.project] + if self.source_project is not self.project: + values.append(self.source_project) + return self.responses(*values, self.source_run, self.branch, self.ancestry) + + def preflight_responses(self): + return self.source_responses() + self.responses(self.comparison, self.associations) + + def select_cross_repository(self): + self.source_project = {"id": 200, "full_name": "example/gradle", "default_branch": "develop", "visibility": "public"} + self.environment.update(SOURCE_REPOSITORY="example/gradle", SOURCE_REPOSITORIES='{"example/gradle":200}') + self.source_run.update(repository=self.source_project, head_repository=self.source_project, head_branch="develop") + self.branch["name"] = "develop" + target = self.associations["data"]["repository"] + target.update(databaseId=200, nameWithOwner="example/gradle") + target["defaultBranchRef"]["name"] = "develop" + for name in ("c0", "c1"): + node = target[name]["associatedPullRequests"]["nodes"][0] + node.update(baseRefName="develop", baseRepository={"databaseId": 200, "nameWithOwner": "example/gradle"}) - def test_actual_prepared_artifact_round_trips_through_coordinator_validation_and_discovery(self): - coordinator_environment = self.environment.copy() - self.select_dispatcher() - self.execute("prepare-source", self.responses(self.project)) - actual = pathlib.Path(self.action_outputs()["source-event-path"]).read_bytes() - (self.directory / "output.txt").unlink() - self.environment = coordinator_environment - self.event = {"repository": self.project} - self.execute("validate-source", self.source_responses()) - (self.directory / "output.txt").unlink() - self.execute("preflight", self.source_responses() + self.responses(self.comparison, self.associations)) - metadata = self.prepared_envelope()["metadata"] - self.assertEqual(json.loads(actual), self.snapshot) + def test_queued_range_preserves_source_identity_not_coordinator_head(self): + self.execute("preflight", self.preflight_responses()) + envelope = self.prepared_envelope() + metadata = envelope["metadata"] + self.assertEqual(metadata["event_name"], "push") self.assertEqual(metadata["push_before"], self.before) self.assertEqual(metadata["push_after"], self.after) + self.assertEqual(metadata["workflow_sha"], self.after) self.assertEqual(metadata["source_tip_sha"], self.tip) - self.assertNotEqual(metadata["workflow_sha"], self.environment["GITHUB_SHA"]) + self.assertEqual(metadata["run_id"], 123456) + self.assertEqual(metadata["coordinator_run_id"], 999) + self.assertEqual(metadata["required_wiki_repository"], self.repository) self.assertEqual(metadata["pull_requests"][0]["merge_commit_sha"], self.merge_sha) + self.assertEqual(len(metadata["pull_requests"]), 1) + self.assertEqual(metadata["range_origin"], "authorized-reconciliation") + self.assertIn("not an attestation of the original push boundary", envelope["request"]["input"]) + self.assertNotIn("fake-source-token", json.dumps(envelope)) + self.assertNotIn("fake-repository-token", json.dumps(envelope)) + self.assertFalse((self.directory / "issuelens-team-memory-source").exists()) + calls = self.opener.open.call_args_list + self.assertEqual(calls[0].args[0].get_header("Authorization"), "Bearer fake-repository-token") + for call in calls[1:]: + self.assertEqual(call.args[0].get_header("Authorization"), "Bearer fake-source-token") + self.assertEqual(call.kwargs["timeout"], 30) + self.assertIn("/attempts/2", calls[1].args[0].full_url) + self.assertIn(f"/compare/{self.before}...{self.after}?per_page=100&page=1", calls[4].args[0].full_url) self.token.assert_not_called() - def test_obsolete_dispatch_entrypoints_are_removed(self): - for command in ("dispatch", "prepare-dispatch", "validate-dispatch"): - with self.subTest(command=command), self.assertRaisesRegex(SystemExit, "Unsupported action command"): - self.execute(command, []) - self.opener.open.assert_not_called() - for name in ("dispatch", "prepare_dispatch", "validate_dispatch", "dispatch_target", "validate_dispatch_target"): - self.assertFalse(hasattr(action, name)) - - def test_source_download_validation_cannot_be_used_by_other_request_adapters(self): - self.environment["REQUEST_TYPE"] = "task" - with self.assertRaisesRegex(SystemExit, "Only team-memory"): - self.execute("validate-source", []) - self.opener.open.assert_not_called() - - def test_verifies_run_attempt_and_artifact_before_download(self): - self.execute("validate-source", self.source_responses()) - self.assertEqual(self.action_outputs(), { - "automatic": "true", "source-run-id": "123456", "source-artifact-id": "456", - }) - self.assertEqual([call.args[0].full_url for call in self.opener.open.call_args_list], [ - "https://api.github.com/repos/microsoft/IssueLens", - "https://api.github.com/repos/microsoft/IssueLens/actions/runs/123456/attempts/2", - "https://api.github.com/repos/microsoft/IssueLens/actions/artifacts/456", - ]) - self.token.assert_not_called() + def test_cross_repository_source_develop_is_independent_of_coordinator_main(self): + self.select_cross_repository() + self.execute("preflight", self.preflight_responses()) + metadata = self.prepared_envelope()["metadata"] + self.assertEqual(metadata["repository"], "example/gradle") + self.assertEqual(metadata["repository_id"], 200) + self.assertEqual(metadata["base_ref"], "develop") + self.assertEqual(metadata["workflow_ref"], "example/gradle/" + action.DISPATCH_WORKFLOW + "@refs/heads/develop") + self.assertEqual(metadata["coordinator_repository"], self.repository) + self.assertEqual(metadata["required_wiki_repository"], self.repository) + for call in self.opener.open.call_args_list[1:]: + request = call.args[0] + self.assertEqual(request.get_header("Authorization"), "Bearer fake-source-token") + if request.full_url.endswith("/graphql"): + self.assertEqual(json.loads(request.data)["variables"], {"owner": "example", "name": "gradle"}) + else: + self.assertIn("/repos/example/gradle/", request.full_url + "/") + + def test_same_repository_is_default_and_unchanged_tip_needs_no_ancestry_read(self): + self.environment["SOURCE_REPOSITORY"] = "" + self.branch["commit"]["sha"] = self.after + self.associations["data"]["repository"]["defaultBranchRef"]["target"]["oid"] = self.after + self.execute("preflight", self.responses(self.project, self.source_run, self.branch, self.comparison, self.associations)) + self.assertEqual(self.prepared_envelope()["metadata"]["source_tip_sha"], self.after) + self.assertEqual(self.opener.open.call_count, 5) + + def test_rebase_rest_identity_lookup_keeps_the_source_token(self): + for name in ("c0", "c1"): + self.associations["data"]["repository"][name]["associatedPullRequests"]["nodes"][0]["mergeCommit"] = None + self.execute("preflight", self.preflight_responses() + self.responses(self.pull)) + metadata = self.prepared_envelope()["metadata"] + self.assertEqual(metadata["pull_requests"][0]["merge_commit_sha"], self.merge_sha) + request = self.opener.open.call_args.args[0] + self.assertEqual(request.full_url, f"https://api.github.com/repos/{self.repository}/pulls/27") + self.assertEqual(request.get_header("Authorization"), "Bearer fake-source-token") + self.assertEqual(sum(call.args[0].full_url.endswith("/pulls/27") + for call in self.opener.open.call_args_list), 1) def test_invalid_or_mixed_source_inputs_fail_before_network(self): original = self.environment.copy() cases = [ - {"SOURCE_RUN_ID": ""}, {"SOURCE_RUN_ATTEMPT": ""}, {"SOURCE_ARTIFACT_ID": ""}, - {"SOURCE_RUN_ID": "1; echo unsafe"}, {"SOURCE_RUN_ATTEMPT": "0"}, {"SOURCE_ARTIFACT_ID": "01"}, - {"DISPATCH_PR": "27"}, {name: "" for name in action.SOURCE_INPUTS}, + {"SOURCE_RUN_ID": ""}, {"SOURCE_RUN_ATTEMPT": ""}, {"PUSH_BEFORE": ""}, {"PUSH_AFTER": ""}, + {"SOURCE_RUN_ID": "1; echo unsafe"}, {"SOURCE_RUN_ATTEMPT": "0"}, {"PUSH_BEFORE": "0" * 40}, + {"PUSH_AFTER": "short"}, {"PUSH_AFTER": self.before}, {"DISPATCH_PR": "27"}, + {"SOURCE_REPOSITORY": "example/private"}, {"SOURCE_GH_TOKEN": " "}, + {"SOURCE_GH_TOKEN": "fake-source-token\nPRIVATE"}, {"SOURCE_GH_TOKEN": "token\x01private"}, + {"SOURCE_GH_TOKEN": "t" * 4097}, + {"SOURCE_REPOSITORY": "example/.."}, {"SOURCE_REPOSITORY": "example/../secret"}, + {"SOURCE_REPOSITORY": "example/\nsecret"}, {"SOURCE_REPOSITORY": "-example/project"}, + {"SOURCE_REPOSITORIES": " " * 4097}, + {"SOURCE_REPOSITORIES": json.dumps({f"example/p{index}": index + 1 for index in range(101)})}, + {"SOURCE_REPOSITORIES": '{"example/gradle":true}'}, {"SOURCE_REPOSITORIES": "[]"}, + {"SOURCE_REPOSITORIES": '{"example/gradle":200,"example/gradle":200}'}, + {"SOURCE_REPOSITORIES": '{"example/gradle":200,"EXAMPLE/gradle":200}'}, ] for changes in cases: with self.subTest(changes=changes): self.environment = {**original, **changes} with self.assertRaises(SystemExit): - self.execute("validate-source", []) + self.execute("preflight", []) self.opener.open.assert_not_called() self.assertFalse((self.directory / "output.txt").exists()) + self.token.assert_not_called() + + def test_coordinator_inputs_are_not_available_to_other_adapters_or_workflows(self): + original = self.environment.copy() + for changes in ( + {"REQUEST_TYPE": "issue-loop"}, {"REQUEST_TYPE": "task", "TASK_INPUT": "Do something"}, + {"GITHUB_WORKFLOW_REF": self.repository + "/" + action.DISPATCH_WORKFLOW + "@refs/heads/main"}, + {"GITHUB_REF": "refs/heads/feature"}, + ): + with self.subTest(changes=changes): + self.environment = {**original, **changes} + with self.assertRaises(SystemExit): + self.execute("preflight", self.responses(self.project)) + self.assertFalse((self.directory / "output.txt").exists()) + self.token.assert_not_called() - def test_forged_source_runs_fail_before_artifact_download_or_login(self): + def test_forged_source_runs_fail_before_discovery_or_login(self): original = copy.deepcopy(self.source_run) cases = [ {"id": 123457}, {"run_attempt": 1}, {"event": "pull_request"}, {"path": ".github/workflows/untrusted.yml"}, {"head_branch": "untrusted"}, - {"head_sha": "short"}, {"repository": {**self.project, "id": 101}}, + {"head_sha": self.tip}, {"repository": {**self.project, "id": 101}}, {"head_repository": {**self.project, "id": 101}}, {"actor": {"login": "unsafe\nactor"}}, ] for changes in cases: with self.subTest(changes=changes): self.source_run = {**original, **changes} with self.assertRaises(SystemExit): - self.execute("validate-source", self.source_responses()) + self.execute("preflight", self.source_responses()) self.assertEqual(self.opener.open.call_count, 2) self.assertFalse((self.directory / "output.txt").exists()) self.token.assert_not_called() - def test_foreign_expired_or_oversized_artifacts_fail_closed(self): - original = copy.deepcopy(self.artifact) - cases = [ - {"id": 457}, {"name": "issuelens-team-memory-source-1"}, {"expired": True}, - {"digest": None}, {"digest": "short"}, - {"size_in_bytes": 0}, {"size_in_bytes": True}, {"size_in_bytes": action.MAX_SOURCE_BYTES + 1}, - {"workflow_run": {**original["workflow_run"], "id": 123457}}, - {"workflow_run": {**original["workflow_run"], "repository_id": 101}}, - {"workflow_run": {**original["workflow_run"], "head_repository_id": 101}}, - {"workflow_run": {**original["workflow_run"], "head_branch": "untrusted"}}, - {"workflow_run": {**original["workflow_run"], "head_sha": self.tip}}, - ] - for changes in cases: + def test_cross_repository_policy_identity_and_visibility_fail_closed(self): + self.select_cross_repository() + original = self.source_project.copy() + for changes in ({"id": 201}, {"full_name": "example/renamed"}, {"visibility": "private"}, {"visibility": "internal"}): with self.subTest(changes=changes): - self.artifact = {**original, **changes} + self.source_project.update(original) + self.source_project.update(changes) with self.assertRaises(SystemExit): - self.execute("validate-source", self.source_responses()) - self.assertFalse((self.directory / "output.txt").exists()) + self.execute("preflight", self.preflight_responses()) + self.assertEqual(self.opener.open.call_count, 2) self.token.assert_not_called() - - def test_queued_push_uses_original_inventory_not_coordinator_head(self): - self.write_source() - self.execute("preflight", self.source_responses() + self.responses(self.comparison, self.associations)) - envelope = self.prepared_envelope() - metadata = envelope["metadata"] - self.assertEqual(metadata["event_name"], "push") - self.assertEqual(metadata["push_before"], self.before) - self.assertEqual(metadata["push_after"], self.after) - self.assertEqual(metadata["workflow_sha"], self.after) - self.assertEqual(metadata["source_tip_sha"], self.tip) - self.assertEqual(metadata["run_id"], 123456) - self.assertEqual(metadata["coordinator_run_id"], 999) - self.assertEqual(metadata["required_wiki_repository"], self.repository) - self.assertEqual(metadata["pull_requests"][0]["merge_commit_sha"], self.merge_sha) - self.assertEqual(len(metadata["pull_requests"]), 1) - self.assertIn("before writing", envelope["request"]["input"]) - self.assertNotIn("fake-repository-token", json.dumps(envelope)) - self.token.assert_not_called() - - def test_source_snapshot_mismatch_missing_file_and_size_fail_before_discovery(self): - original = copy.deepcopy(self.snapshot) - for mutation in ("metadata", "head", "body", "forced", "oversized", "missing"): + self.source_project.update(original) + self.project["visibility"] = "private" + with self.assertRaisesRegex(SystemExit, "public"): + self.execute("preflight", self.preflight_responses()) + + def test_branch_ancestry_and_ref_races_fail_before_agent_login(self): + originals = copy.deepcopy((self.branch, self.ancestry, self.associations)) + for mutation in ("branch", "sha", "diverged", "merge_base", "ref_race"): with self.subTest(mutation=mutation): - self.snapshot = copy.deepcopy(original) - if mutation == "metadata": - self.snapshot["metadata"]["run_attempt"] = 1 - elif mutation == "head": - self.snapshot["event"]["after"] = self.tip - elif mutation == "body": - self.snapshot["event"]["commits"][0]["message"] = "UNTRUSTED" - elif mutation == "forced": - self.snapshot["event"]["forced"] = True - path = self.write_source(b"x" * (action.MAX_SOURCE_BYTES + 1) if mutation == "oversized" else None) - if mutation == "missing": - path.unlink() + self.branch, self.ancestry, self.associations = copy.deepcopy(originals) + if mutation == "branch": + self.branch["name"] = "feature" + elif mutation == "sha": + self.branch["commit"]["sha"] = "short" + elif mutation == "diverged": + self.ancestry["behind_by"] = 1 + elif mutation == "merge_base": + self.ancestry["merge_base_commit"]["sha"] = self.before + else: + self.associations["data"]["repository"]["defaultBranchRef"]["target"]["oid"] = "1" * 40 with self.assertRaises(SystemExit): - self.execute("preflight", self.source_responses()) - self.assertEqual(self.opener.open.call_count, 3) + self.execute("preflight", self.preflight_responses()) self.assertFalse((self.directory / "output.txt").exists()) self.token.assert_not_called() - def test_truncated_discovery_and_api_errors_do_not_submit_partial_batches(self): - self.write_source() - for response in ({**self.comparison, "total_commits": 3}, OSError("PRIVATE API DETAIL")): - with self.subTest(response=response): - responses = self.source_responses() - responses += [response] if isinstance(response, Exception) else self.responses(response) + def test_range_rejects_truncated_duplicate_divergent_or_missing_head_inventory(self): + original = copy.deepcopy(self.comparison) + for changes in ( + {"commits": [{"sha": self.after}]}, + {"commits": [{"sha": self.after}, {"sha": self.after}]}, + {"commits": [{"sha": self.merge_sha}, {"sha": self.before}]}, + {"commits": [{"sha": self.merge_sha}, {"sha": self.tip}]}, + {"commits": [{"sha": self.merge_sha}, {"sha": "short"}]}, + {"status": "diverged"}, {"ahead_by": 3}, {"total_commits": 1001}, + {"total_commits": True}, {"total_commits": 0}, + {"merge_base_commit": {"sha": self.tip}}, + ): + with self.subTest(changes=changes): + self.comparison = {**original, **changes} with self.assertRaises(SystemExit): - self.execute("preflight", responses) + self.execute("preflight", self.preflight_responses()) self.assertFalse((self.directory / "output.txt").exists()) self.token.assert_not_called() - def test_valid_push_without_newly_merged_prs_skips_before_azure_login(self): - self.write_source() + def test_complete_comparison_pagination_covers_1000_and_requires_every_page(self): + shas = [f"{index:040x}" for index in range(1, 1000)] + [self.after] + pages = [{**self.comparison, "ahead_by": 1000, "total_commits": 1000, + "commits": [{"sha": sha} for sha in shas[start:start + 100]]} + for start in range(0, 1000, 100)] + with patch.dict(os.environ, self.environment, clear=True), patch.object(action, "github_read", side_effect=pages) as read: + result = action.read_reconciliation_inventory(self.repository, self.before, self.after, float("inf")) + self.assertEqual(result, shas) + self.assertEqual(read.call_count, 10) + self.assertTrue(read.call_args.args[0].endswith("?per_page=100&page=10")) + for mutation in ("missing", "duplicate", "count", "base"): + with self.subTest(mutation=mutation): + changed = copy.deepcopy(pages) + if mutation == "missing": + changed[1]["commits"].pop() + elif mutation == "duplicate": + changed[1]["commits"][0] = changed[0]["commits"][0] + elif mutation == "count": + changed[1]["total_commits"] = 999 + else: + changed[1]["base_commit"]["sha"] = self.tip + with patch.dict(os.environ, self.environment, clear=True), \ + patch.object(action, "github_read", side_effect=changed), self.assertRaises(ValueError): + action.read_reconciliation_inventory(self.repository, self.before, self.after, float("inf")) + + def test_partial_final_page_and_discovery_deadline(self): + shas = [f"{index:040x}" for index in range(1, 102)] + [self.after] + pages = [{**self.comparison, "ahead_by": len(shas), "total_commits": len(shas), + "commits": [{"sha": sha} for sha in shas[start:start + 100]]} + for start in range(0, len(shas), 100)] + with patch.dict(os.environ, self.environment, clear=True), patch.object(action, "github_read", side_effect=pages): + self.assertEqual(action.read_reconciliation_inventory(self.repository, self.before, self.after, float("inf")), shas) + with patch.dict(os.environ, self.environment, clear=True), patch.object(action, "github_read") as read, \ + self.assertRaisesRegex(ValueError, "time budget"): + action.read_reconciliation_inventory(self.repository, self.before, self.after, 0) + read.assert_not_called() + + def test_network_errors_do_not_retry_or_expose_credentials(self): + responses = self.source_responses() + [OSError("fake-source-token PRIVATE API DETAIL")] + with self.assertRaises(SystemExit) as raised: + self.execute("preflight", responses) + self.assertEqual(self.opener.open.call_count, 5) + self.assertNotIn("fake-source-token", str(raised.exception)) + self.assertNotIn("PRIVATE", str(raised.exception)) + self.assertFalse((self.directory / "output.txt").exists()) + self.token.assert_not_called() + + def test_valid_range_without_newly_merged_prs_skips_before_azure_login(self): for name in ("c0", "c1"): self.associations["data"]["repository"][name]["associatedPullRequests"] = { "totalCount": 0, "pageInfo": {"hasNextPage": False}, "nodes": [], } - self.execute("preflight", self.source_responses() + self.responses(self.comparison, self.associations)) + self.execute("preflight", self.preflight_responses()) self.assertEqual(self.action_outputs()["eligible"], "false") self.assertEqual(self.action_outputs()["skip-reason"], "no_merged_pull_requests") self.token.assert_not_called() def test_queued_request_preserves_the_existing_batch_receipt_contract(self): - self.write_source() - self.execute("preflight", self.source_responses() + self.responses(self.comparison, self.associations)) + self.execute("preflight", self.preflight_responses()) prepared = self.prepared_envelope() self.environment["REQUEST_PATH"] = self.action_outputs()["request-path"] (self.directory / "output.txt").unlink() @@ -357,11 +347,8 @@ def test_queued_request_preserves_the_existing_batch_receipt_contract(self): self.assertEqual(self.opener.open.call_count, 1) self.token.assert_called_once() - def test_manual_pr_selection_uses_the_same_coordinator_without_source_artifacts(self): + def test_manual_pr_selection_uses_the_same_coordinator_without_range_inputs(self): self.environment.update({name: "" for name in action.SOURCE_INPUTS}, DISPATCH_PR="27") - self.execute("validate-source", self.responses(self.project)) - self.assertEqual(self.action_outputs(), {"automatic": "false"}) - (self.directory / "output.txt").unlink() self.execute("preflight", self.responses(self.project, self.pull)) metadata = self.prepared_envelope()["metadata"] self.assertEqual(metadata["pull_number"], 27) @@ -370,21 +357,16 @@ def test_manual_pr_selection_uses_the_same_coordinator_without_source_artifacts( self.assertEqual(self.opener.open.call_count, 2) self.token.assert_not_called() - def test_coordinator_inputs_are_not_available_to_other_adapters_or_repositories(self): - original = self.environment.copy() - for changes in ( - {"REQUEST_TYPE": "issue-loop"}, {"REQUEST_TYPE": "task", "TASK_INPUT": "Do something"}, - {"GITHUB_REPOSITORY": "microsoft/vscode-java-pack"}, - {"GITHUB_WORKFLOW_REF": self.source_metadata["workflow_ref"]}, - ): - with self.subTest(changes=changes): - self.environment = {**original, **changes} - if changes.get("GITHUB_REPOSITORY"): - self.event["repository"] = {**self.project, "full_name": changes["GITHUB_REPOSITORY"]} - with self.assertRaises(SystemExit): - self.execute("preflight", self.source_responses()) - self.assertFalse((self.directory / "output.txt").exists()) - self.token.assert_not_called() + def test_manual_cross_source_requires_allowlist_and_uses_source_read_credentials(self): + self.select_cross_repository() + self.environment.update({name: "" for name in action.SOURCE_INPUTS}, DISPATCH_PR="27") + self.pull["base"] = {"ref": "develop", "repo": self.source_project} + self.execute("preflight", self.responses(self.project, self.source_project, self.pull)) + metadata = self.prepared_envelope()["metadata"] + self.assertEqual(metadata["repository"], "example/gradle") + self.assertEqual(metadata["base_ref"], "develop") + self.assertEqual(metadata["coordinator_repository"], self.repository) + self.assertEqual(self.opener.open.call_args.args[0].get_header("Authorization"), "Bearer fake-source-token") def test_coordinator_wiki_identity_is_required_without_overriding_policy(self): self.envelope["metadata"].update(repository=self.repository, required_wiki_repository=self.repository) @@ -394,8 +376,16 @@ def test_coordinator_wiki_identity_is_required_without_overriding_policy(self): self.assertFalse((self.directory / "output.txt").exists()) self.assertIn("never overrides repository policy", action.build_team_memory_request(self.envelope["metadata"])["input"]) - def test_other_callers_can_still_use_the_same_workflow_filename(self): - self.environment.update({name: "" for name in action.SOURCE_INPUTS}, DISPATCH_PR="27") + def test_obsolete_artifact_and_dispatch_entrypoints_are_removed(self): + for command in ("dispatch", "prepare-dispatch", "validate-dispatch", "prepare-source", "validate-source"): + with self.subTest(command=command), self.assertRaisesRegex(SystemExit, "Unsupported action command"): + self.execute(command, []) + self.opener.open.assert_not_called() + for name in ("prepare_source", "validate_source", "source_event_path", "read_source_event", "team_memory_source_snapshot"): + self.assertFalse(hasattr(action, name)) + + def test_other_direct_callers_can_still_use_the_same_workflow_filename(self): + self.environment.update({name: "" for name in (*action.SOURCE_INPUTS, "SOURCE_REPOSITORY")}, DISPATCH_PR="27") self.environment["GITHUB_REPOSITORY"] = "example/project" self.environment["GITHUB_WORKFLOW_REF"] = "example/project/" + action.COORDINATOR_WORKFLOW + "@refs/heads/main" self.project["full_name"] = "example/project" @@ -403,18 +393,6 @@ def test_other_callers_can_still_use_the_same_workflow_filename(self): self.execute("preflight", self.responses(self.project, self.pull)) self.assertNotIn("required_wiki_repository", self.prepared_envelope()["metadata"]) - def test_coordinator_preflight_revalidates_after_source_download(self): - self.write_source() - self.execute("validate-source", self.source_responses()) - (self.directory / "output.txt").unlink() - self.artifact["expired"] = True - with self.assertRaises(SystemExit): - self.execute("preflight", self.source_responses()) - self.assertFalse((self.directory / "output.txt").exists()) - self.token.assert_not_called() - - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_team_memory_workflow.py b/tests/test_team_memory_workflow.py index 505db2f..dfe2ef9 100644 --- a/tests/test_team_memory_workflow.py +++ b/tests/test_team_memory_workflow.py @@ -65,7 +65,7 @@ def test_default_branch_push_and_manual_target(self): def test_preflight_precedes_pinned_login_and_submission(self): self.assertEqual(self.action_metadata["runs"]["using"], "composite") - verify, download, preflight, login, submit = self.action_metadata["runs"]["steps"] + preflight, login, submit = self.action_metadata["runs"]["steps"] self.assertEqual(preflight["id"], "preflight") self.assertRegex(login["uses"], r"^azure/login@[0-9a-f]{40}\Z") for step in (login, submit): @@ -87,9 +87,13 @@ def test_preflight_precedes_pinned_login_and_submission(self): self.assertEqual(preflight["env"]["REQUEST_TYPE"], "${{ inputs.request-type }}") self.assertEqual(preflight["env"]["TASK_INPUT"], "${{ inputs.input }}") self.assertEqual(preflight["env"]["ISSUE_NUMBER"], "${{ inputs.issue-number }}") - for name in ("source-run-id", "source-run-attempt", "source-artifact-id"): + for name in ("source-repository", "source-run-id", "source-run-attempt", "push-before", "push-after"): self.assertEqual(preflight["env"][name.upper().replace("-", "_")], "${{ inputs." + name + " }}") self.assertEqual(self.action_metadata["inputs"][name]["default"], "") + self.assertEqual(preflight["env"]["SOURCE_GH_TOKEN"], "${{ inputs.source-github-token }}") + self.assertEqual(preflight["env"]["SOURCE_REPOSITORIES"], "${{ inputs.source-repositories }}") + self.assertEqual(self.action_metadata["inputs"]["source-repositories"]["default"], "{}") + self.assertEqual(self.action_metadata["inputs"]["source-github-token"]["default"], "${{ github.token }}") self.assertEqual(self.action_metadata["inputs"]["request-type"]["required"], "true") for step in (preflight, submit): self.assertEqual(step["env"]["OUTPUT_MODE"], "${{ inputs.output-mode }}") @@ -121,59 +125,44 @@ def test_all_issuelens_requests_share_the_wiki_queue(self): def test_dispatcher_has_no_agent_credentials_or_invocation(self): self.assertEqual(self.dispatch_job["timeout-minutes"], "10") steps = self.dispatch_job["steps"] - self.assertEqual(len(steps), 4) - prepare, upload, dispatch = steps[1:] - self.assertEqual(prepare["run"], "python3 -I .github/actions/issuelens/issuelens_action.py prepare-source") - self.assertEqual(prepare["env"], {"GH_TOKEN": "${{ github.token }}"}) - self.assertEqual(dispatch["uses"], "./.github/actions/queue-team-memory") + dispatch, = steps + self.assertRegex(dispatch["uses"], r"^microsoft/IssueLens/.github/actions/queue-team-memory@[0-9a-f]{40}$") self.assertEqual(dispatch["with"]["coordinator-workflow"], "team-memory-coordinator.yml") + self.assertEqual(dispatch["with"]["coordinator-ref"], "main") self.assertEqual(json.loads(dispatch["with"]["workflow-inputs"]), { + "source_repository": "${{ github.repository }}", "source_run_id": "${{ github.run_id }}", "source_run_attempt": "${{ github.run_attempt }}", - "source_artifact_id": "${{ steps.artifact.outputs.artifact-id }}", + "push_before": "${{ github.event.before }}", "push_after": "${{ github.sha }}", }) for forbidden in ("secrets.", "id-token", "azure/login", "agent-url", "request-type:", "pull_request"): self.assertNotIn(forbidden, self.dispatch_source + self.queue_source) - def test_source_workflow_owns_preparation_upload_and_dispatch_inputs(self): + def test_source_workflow_needs_no_checkout_scripts_or_artifacts(self): self.assertEqual(self.queue_metadata["runs"]["using"], "composite") - prepare, upload, dispatch = self.dispatch_job["steps"][1:] - self.assertEqual(prepare["id"], "source") - self.assertEqual(upload["id"], "artifact") - self.assertRegex(upload["uses"], r"^actions/upload-artifact@[0-9a-f]{40}$") - self.assertEqual(upload["with"], { - "name": "issuelens-team-memory-source-${{ github.run_attempt }}", - "path": "${{ steps.source.outputs.source-event-path }}", - "if-no-files-found": "error", "retention-days": "7", - }) - self.assertEqual(prepare["env"], {"GH_TOKEN": "${{ github.token }}"}) - for step in (prepare, upload, dispatch): - self.assertNotIn("if", step) - self.assertNotIn("continue-on-error", step) + dispatch, = self.dispatch_job["steps"] + self.assertNotIn("continue-on-error", dispatch) + self.assertNotIn("run", dispatch) + for flag in ("created", "deleted", "forced"): + self.assertIn(f"github.event.{flag} == false", self.dispatch_job["if"]) + self.assertIn("github.workflow_sha == github.sha", self.dispatch_job["if"]) + for forbidden in ("actions/checkout", "artifact", "prepare-source", "issuelens_action.py", "commits", "message"): + self.assertNotIn(forbidden, self.dispatch_source) only_dispatch, = self.queue_metadata["runs"]["steps"] self.assertEqual(only_dispatch["run"], 'python3 -I "$GITHUB_ACTION_PATH/dispatch.py"') for forbidden in ("concurrency", "permissions", "actions/checkout", "azure/login", "pip install", "upload-artifact", "source-token", "../issuelens"): self.assertNotIn(forbidden, self.queue_source) - def test_invocation_action_validates_source_before_download_and_agent_login(self): + def test_invocation_action_owns_preflight_and_has_no_artifact_path(self): checkout, invoke = self.steps - verify, download, preflight, login, submit = self.action_metadata["runs"]["steps"] - self.assertEqual(verify["id"], "source") - self.assertEqual(verify["run"], 'python3 -I "$GITHUB_ACTION_PATH/issuelens_action.py" validate-source') - self.assertEqual(verify["env"]["GH_TOKEN"], "${{ inputs.github-token }}") - self.assertIn("inputs.request-type == 'team-memory'", verify["if"]) + preflight, login, submit = self.action_metadata["runs"]["steps"] self.assertTrue(all("run" not in step for step in self.steps)) - self.assertRegex(download["uses"], r"^actions/download-artifact@[0-9a-f]{40}$") - self.assertEqual(download["if"], "steps.source.outputs.automatic == 'true'") - self.assertEqual(download["with"], { - "artifact-ids": "${{ steps.source.outputs.source-artifact-id }}", - "run-id": "${{ steps.source.outputs.source-run-id }}", - "repository": "${{ github.repository }}", "github-token": "${{ inputs.github-token }}", - "path": "${{ runner.temp }}/issuelens-team-memory-source", "digest-mismatch": "error", - }) - for name in ("source_run_id", "source_run_attempt", "source_artifact_id"): - self.assertEqual(verify["env"][name.upper()], "${{ inputs." + name.replace("_", "-") + " }}") + for name in ("source_repository", "source_run_id", "source_run_attempt", "push_before", "push_after"): + self.assertEqual(preflight["env"][name.upper()], "${{ inputs." + name.replace("_", "-") + " }}") self.assertEqual(invoke["with"][name.replace("_", "-")], "${{ inputs." + name + " }}") + self.assertNotIn("source-artifact-id", self.action_metadata["inputs"]) + self.assertNotIn("artifact", json.dumps(self.action_metadata)) + self.assertNotIn("source-repositories", invoke["with"]) self.assertEqual(login["if"], "steps.preflight.outputs.eligible == 'true'") self.assertTrue(all("${{" not in step["run"] for step in self.action_metadata["runs"]["steps"] if "run" in step)) @@ -186,15 +175,12 @@ def test_job_timeout_has_setup_and_receipt_headroom(self): self.assertIn("30-minute job timeout", (ACTION_DIR / "README.md").read_text(encoding="utf-8")) def test_local_caller_loads_only_trusted_action_revision(self): - for checkout in (self.steps[0], self.dispatch_job["steps"][0]): - self.assertRegex(checkout["uses"], r"^actions/checkout@[0-9a-f]{40}\Z") - self.assertEqual(checkout["with"]["ref"], "${{ github.workflow_sha }}") - self.assertEqual(checkout["with"]["persist-credentials"], "false") - self.assertEqual(checkout["with"]["sparse-checkout-cone-mode"], "false") + checkout = self.steps[0] + self.assertRegex(checkout["uses"], r"^actions/checkout@[0-9a-f]{40}\Z") + self.assertEqual(checkout["with"]["ref"], "${{ github.workflow_sha }}") + self.assertEqual(checkout["with"]["persist-credentials"], "false") + self.assertEqual(checkout["with"]["sparse-checkout-cone-mode"], "false") self.assertEqual(self.steps[0]["with"]["sparse-checkout"], "/.github/actions/issuelens/") - self.assertEqual(self.dispatch_job["steps"][0]["with"]["sparse-checkout"].splitlines(), [ - "/.github/actions/issuelens/", "/.github/actions/queue-team-memory/", - ]) invoke = self.steps[-1] self.assertEqual(invoke["uses"], "./.github/actions/issuelens") self.assertEqual(invoke["with"]["request-type"], "team-memory") @@ -232,7 +218,8 @@ def test_queue_action_remote_example_is_pinned_and_preserves_pilot_scope(self): self.assertEqual(invocation["uses"], "microsoft/IssueLens/.github/actions/queue-team-memory@FULL_COMMIT_SHA") self.assertEqual(invocation["with"], { "coordinator-workflow": "team-memory-coordinator.yml", - "workflow-inputs": '{"source_run_id":"${{ github.run_id }}", "source_run_attempt":"${{ github.run_attempt }}", "source_artifact_id":"${{ steps.artifact.outputs.artifact-id }}"}', + "coordinator-ref": "main", + "workflow-inputs": '{"source_repository":"${{ github.repository }}", "source_run_id":"${{ github.run_id }}", "source_run_attempt":"${{ github.run_attempt }}", "push_before":"${{ github.event.before }}", "push_after":"${{ github.sha }}"}', }) self.assertNotIn("actions/checkout", example) self.assertIn("does not prepare", guide) @@ -240,7 +227,7 @@ def test_queue_action_remote_example_is_pinned_and_preserves_pilot_scope(self): self.assertIn("Actions write", guide) self.assertIn("does not expand repository access", guide) - def test_queue_action_generic_example_matches_the_four_input_contract(self): + def test_queue_action_cross_repo_example_matches_the_range_contract(self): guide = (QUEUE_ACTION_DIR / "README.md").read_text(encoding="utf-8") example = guide.split("```yaml\n")[2].split("```", 1)[0] invocation = yaml.load(example, Loader=yaml.BaseLoader)[0] @@ -249,10 +236,10 @@ def test_queue_action_generic_example_matches_the_four_input_contract(self): "dispatch-token": "${{ steps.dispatch-token.outputs.token }}", "coordinator-repository": "microsoft/vscode-java-pack", "coordinator-workflow": "team-memory-coordinator.yml", "coordinator-ref": "main", - "workflow-inputs": '{"source_repository":"${{ github.repository }}", "source_run_id":"${{ github.run_id }}", "source_run_attempt":"${{ github.run_attempt }}", "source_artifact_id":"${{ steps.artifact.outputs.artifact-id }}"}', + "workflow-inputs": '{"source_repository":"${{ github.repository }}", "source_run_id":"${{ github.run_id }}", "source_run_attempt":"${{ github.run_attempt }}", "push_before":"${{ github.event.before }}", "push_after":"${{ github.sha }}"}', }) self.assertEqual(set(invocation["with"]), set(self.queue_metadata["inputs"])) - for field in ("source_repository", "source_run_id", "source_run_attempt", "source_artifact_id"): + for field in ("source_repository", "source_run_id", "source_run_attempt", "push_before", "push_after"): self.assertIn(field, guide) self.assertIn("develop", guide) self.assertNotIn("actions/checkout", example) @@ -573,21 +560,24 @@ def test_helper_cli_runs_outside_the_repository_in_isolated_mode(self): self.assertNotIn("ImportError", result.stderr) self.assertFalse((self.directory / "output.txt").exists()) - def test_source_helper_runs_from_downloaded_invocation_action_outside_checkout(self): + def test_reconciliation_helper_runs_from_downloaded_action_outside_checkout(self): bundle = self.directory / "downloaded-action" shutil.copytree(ACTION_DIR, bundle, ignore=shutil.ignore_patterns("__pycache__")) helper = bundle / "issuelens_action.py" caller = self.directory / "caller" caller.mkdir() - for command in ("prepare-source",): - with self.subTest(command=command): - result = subprocess.run( - [sys.executable, "-I", str(helper), command], - cwd=caller, env=self.environment, capture_output=True, text=True, timeout=15, - ) - self.assertEqual(result.returncode, 1) - self.assertIn("Only pushes", result.stderr) - self.assertNotIn("ImportError", result.stderr) + environment = { + **self.environment, "SOURCE_RUN_ID": "123456", "SOURCE_RUN_ATTEMPT": "2", + "PUSH_BEFORE": "d" * 40, "PUSH_AFTER": "e" * 40, "SOURCE_GH_TOKEN": "fake-source-token", "DISPATCH_PR": "", + } + pathlib.Path(environment["GITHUB_EVENT_PATH"]).write_text(json.dumps(self.event), encoding="utf-8") + result = subprocess.run( + [sys.executable, "-I", str(helper), "preflight"], + cwd=caller, env=environment, capture_output=True, text=True, timeout=15, + ) + self.assertEqual(result.returncode, 1) + self.assertIn("requires a workflow dispatch", result.stderr) + self.assertNotIn("ImportError", result.stderr) self.assertEqual(list(caller.iterdir()), []) self.assertFalse((self.directory / "output.txt").exists()) diff --git a/tests/test_workflow_dispatch_action.py b/tests/test_workflow_dispatch_action.py index 746461a..8250c72 100644 --- a/tests/test_workflow_dispatch_action.py +++ b/tests/test_workflow_dispatch_action.py @@ -103,9 +103,10 @@ def test_single_authenticated_post_preserves_arbitrary_caller_inputs_and_branch_ def test_same_repo_pilot_and_cross_repo_job_payloads_are_caller_owned(self): cases = [ - ("example/central", {"source_run_id": "123456", "source_run_attempt": "2", "source_artifact_id": "456"}), + ("example/central", {"source_run_id": "123456", "source_run_attempt": "2", + "push_before": "a" * 40, "push_after": "b" * 40}), ("example/source", {"source_repository": "example/source", "source_run_id": "123456", - "source_run_attempt": "2", "source_artifact_id": "456"}), + "source_run_attempt": "2", "push_before": "a" * 40, "push_after": "b" * 40}), ("unrelated/source", {"pull_request_number": "27"}), ] for source, inputs in cases: