diff --git a/plugins/dev-team/skills/resolve-pr-reference/SKILL.md b/plugins/dev-team/skills/resolve-pr-reference/SKILL.md new file mode 100644 index 0000000..3041a70 --- /dev/null +++ b/plugins/dev-team/skills/resolve-pr-reference/SKILL.md @@ -0,0 +1,107 @@ +--- +name: resolve-pr-reference +user-invocable: false +description: > + Use when you need to resolve a PR reference — a bare number/`#123`, a full GitHub PR URL, + or a work-item ID — to exactly one owner/repo#number. Delegates every deterministic path to + resolve_pr_reference.py; makes one inline getJiraIssueRemoteIssueLinks MCP call itself for + the Jira work-item path. +argument-hint: +--- + +Use this skill when: +- You have a PR reference (a bare number, `#123`, a full GitHub PR URL, or a work-item ID) + and need it resolved to exactly one `owner/repo#number`, or a structured failure + +`` below refers to this skill's own base directory — the "Base directory for this +skill" path shown when this skill was invoked. Resolve it to that literal path; it is not an +environment variable. + +## Resolution paths + +Every path except the Jira work-item path is fully deterministic and handled by +`resolve_pr_reference.py`: + +- A full GitHub PR URL (`https://github.com///pull/`) resolves to that + URL's own owner/repo/number, independent of the current repo. +- A bare PR number, or `#123`, resolves against the current repo's `origin`. +- A ref matching a configured provider's `work-tracking..issue-key-pattern`/ + `recognize-patterns` (via `get-project-configuration`) is treated as a work-item ID: + - First, an existing `use-context-file` context file for that work-item ID is checked; if + found and its `pr_url` field is set, that's used directly — no provider is queried. + - Otherwise, dispatch by provider: + - **GitHub**: `resolve_pr_reference.py` calls the new `getLinkedPullRequests` operation + (documented in `work-with-GitHub-issues`) directly — a plain `gh api graphql` call, no + MCP tool needed. + - **Jira**: the first script call (no `--jira-links` flag) reports `status: "needs_jira_links"` + instead of resolving — see below, this is the one path this skill itself performs a + tool-use call for. +- A ref matching none of the above shapes is `not_found`, with `detail` distinguishing it from a + well-formed reference that resolved to nothing. + +## Steps + +### 1 — Run the script without Jira links + +```bash +python3 "/scripts/resolve_pr_reference.py" "" +``` + +If the result's `status` is `resolved`, `not_found`, `ambiguous`, or `access_denied`, that result +is final — skip step 2. + +If the result's `status` is `needs_jira_links`, the ref matched the `jira` provider's configured +patterns and no `use-context-file` context file already resolved it — proceed to step 2. + +The script also exits non-zero with `Error: ...` on stderr for a hard failure unrelated to ref +resolution itself (not part of the documented JSON contract) — stop and report that verbatim +rather than treating it as a resolution result. + +### 2 — Jira work-item path only + +Run this step only when step 1 returned `status: "needs_jira_links"`. + +Call the `getJiraIssueRemoteIssueLinks` operation (documented in `work-with-Jira-tasks`'s +Operations table) with the work-item's issue key, to fetch its Remote Issue Links. Then re-run +the script, passing the result as JSON: + +```bash +python3 "/scripts/resolve_pr_reference.py" "" --jira-links '' +``` + +If `getJiraIssueRemoteIssueLinks` returns nothing, pass an empty array (`[]`) — the script's own +GitHub-search fallback (`gh pr list`/`gh search prs` for the issue key in title, body, or branch +name) runs before reporting `not_found`. This second call's result (`resolved`, `not_found`, or +`ambiguous`) is always final. + +## Output contract + +On success, one JSON object on stdout: + +```json +{"status": "resolved", "owner": "...", "repo": "...", "number": 123, + "pr_url": "https://github.com/.../pull/123", + "source": "url" | "number" | "work-item-context-file" | "work-item-jira-remote-link" + | "work-item-jira-github-search" | "work-item-github-linked-pr"} +``` + +On a pending Jira lookup (only possible from step 1, before step 2 has run): + +```json +{"status": "needs_jira_links", "detail": ""} +``` + +On failure: + +```json +{"status": "not_found" | "ambiguous" | "access_denied", "detail": ""} +``` + +- `not_found` — the ref didn't match any recognized shape, a matched provider pattern resolved + to zero PRs (even after any fallback), or a resolved PR doesn't exist. +- `ambiguous` — more than one PR was found for a work-item ID; never guessed. +- `access_denied` — a resolved PR exists but isn't accessible (e.g. a private repo without + permission). + +Callers (worktree creation, evidence gathering, and other later PR-review-guide deliverables) +consume this JSON directly rather than re-deriving any of the above resolution logic themselves. diff --git a/plugins/dev-team/skills/resolve-pr-reference/scripts/resolve_pr_reference.py b/plugins/dev-team/skills/resolve-pr-reference/scripts/resolve_pr_reference.py new file mode 100644 index 0000000..dd90f0b --- /dev/null +++ b/plugins/dev-team/skills/resolve-pr-reference/scripts/resolve_pr_reference.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +"""Resolve a PR reference to exactly one owner/repo#number. + +Usage: + resolve_pr_reference.py "" + resolve_pr_reference.py "" --jira-links '' + +`` may be a full GitHub PR URL, a bare PR number (or `#123`), or a work-item ID. +Handles every deterministic resolution path itself: ref-shape classification, `gh pr view` +existence/access checks, the `use-context-file` context-file read, the GitHub-search fallback, +and the GitHub GraphQL linked-PR lookup (`getLinkedPullRequests`). The one Jira-specific step — +calling the `getJiraIssueRemoteIssueLinks` MCP operation — is not reachable from a script; the +`resolve-pr-reference` skill calls it itself and passes the result back in via `--jira-links`. + +`main()` is a thin CLI wrapper: prints `resolve_pr_reference()`'s result as one JSON object to +stdout with exit 0, whether it resolved, reports a structured `not_found`/`ambiguous`/ +`access_denied` failure, or (Jira work-item path only, first call with no `--jira-links` flag) +reports `needs_jira_links` — a distinct pending status telling the caller to fetch +`getJiraIssueRemoteIssueLinks` and re-invoke with `--jira-links` before a final result is +possible. A hard failure of the script itself (e.g. malformed `--jira-links` JSON) prints +`Error: ...` to stderr and exits non-zero instead. +""" + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +_WORKFLOW_ORCHESTRATE_SCRIPTS_DIR = ( + Path(__file__).resolve().parent.parent.parent / "workflow-orchestrate" / "scripts" +) +_MERGE_CONFIG_SCRIPTS_DIR = ( + Path(__file__).resolve().parent.parent.parent / "get-project-configuration" / "scripts" +) + +sys.path.insert(0, str(_WORKFLOW_ORCHESTRATE_SCRIPTS_DIR)) +sys.path.insert(0, str(_MERGE_CONFIG_SCRIPTS_DIR)) + +from dev_team import compute_context_path # noqa: E402 +from get_context_path import get_repo_slug # noqa: E402 +from pipeline_context import PipelineContext # noqa: E402 +from merge_config import YamlParseError, build_merged_config # noqa: E402 + +_PR_URL_RE = re.compile(r"^https://github\.com/([^/]+)/([^/]+)/pull/(\d+)$") +_BARE_NUMBER_RE = re.compile(r"^#?(\d+)$") + +_LINKED_PRS_QUERY = ( + "query($owner: String!, $repo: String!, $number: Int!) {" + " repository(owner: $owner, name: $repo) {" + " issue(number: $number) {" + " closedByPullRequestsReferences(first: 10) { nodes { number } } } } }" +) + + +# --------------------------------------------------------------------------- +# gh pr view — existence/access checks shared by the url and number paths +# --------------------------------------------------------------------------- + +def _classify_gh_error(stderr: str) -> str: + """Heuristic: a permission/authentication signal in gh's stderr is access_denied; anything + else (not found, malformed reference, generic API error) is not_found. Wording of gh's own + error text isn't a stable contract, so this only looks for common access-related keywords.""" + lowered = (stderr or "").lower() + if "403" in lowered or "permission" in lowered or "access" in lowered or "authenticat" in lowered: + return "access_denied" + return "not_found" + + +def _resolve_pr(owner: str, repo: str, number: int, source: str) -> dict: + pr_url = f"https://github.com/{owner}/{repo}/pull/{number}" + result = subprocess.run( + ["gh", "pr", "view", pr_url, "--json", "number,url,state"], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + status = _classify_gh_error(result.stderr) + return {"status": status, "detail": f"gh pr view failed for {pr_url}: {result.stderr.strip() or 'unknown error'}"} + try: + json.loads(result.stdout) + except json.JSONDecodeError: + return {"status": "not_found", "detail": f"gh pr view returned unexpected output for {pr_url}"} + return { + "status": "resolved", + "owner": owner, + "repo": repo, + "number": number, + "pr_url": pr_url, + "source": source, + } + + +# --------------------------------------------------------------------------- +# Repo-slug helpers +# --------------------------------------------------------------------------- + +def _split_repo_slug(slug: str) -> tuple[str | None, str | None]: + parts = slug.split("/") + if len(parts) != 2: + return None, None + return parts[0], parts[1] + + +def _resolve_bare_number(number: int) -> dict: + owner, repo = _split_repo_slug(get_repo_slug()) + if owner is None: + return { + "status": "not_found", + "detail": "could not parse owner/repo from the current repo's git remote 'origin' to resolve a bare PR number", + } + return _resolve_pr(owner, repo, number, source="number") + + +# --------------------------------------------------------------------------- +# Work-item-id pattern matching +# --------------------------------------------------------------------------- + +def _match_work_item_provider(ref: str, work_tracking: dict) -> str | None: + if not isinstance(work_tracking, dict): + return None + for provider_name, provider_config in work_tracking.items(): + if not isinstance(provider_config, dict): + continue + patterns = [] + issue_key_pattern = provider_config.get("issue-key-pattern") + if issue_key_pattern: + patterns.append(issue_key_pattern) + patterns.extend(provider_config.get("recognize-patterns") or []) + for pattern in patterns: + try: + if re.fullmatch(pattern, ref): + return provider_name + except re.error: + continue + return None + + +def _context_file_pr_url(work_item_id: str, repo_slug: str) -> str | None: + path = compute_context_path(work_item_id, repo_slug) + if not path.exists(): + return None + ctx = PipelineContext.load(path) + return ctx.pr_url or None + + +def _finalize_matches(pr_refs: list[tuple[str, str, int]], source: str, ref: str) -> dict: + unique = sorted(set(pr_refs)) + if not unique: + return {"status": "not_found", "detail": f"no PR found for work item {ref!r} ({source} path)"} + if len(unique) > 1: + listed = ", ".join(f"{owner}/{repo}#{number}" for owner, repo, number in unique) + return {"status": "ambiguous", "detail": f"multiple PRs linked to work item {ref!r}: {listed}"} + owner, repo, number = unique[0] + return { + "status": "resolved", + "owner": owner, + "repo": repo, + "number": number, + "pr_url": f"https://github.com/{owner}/{repo}/pull/{number}", + "source": source, + } + + +# --------------------------------------------------------------------------- +# Jira work-item path: remote links, then GitHub-search fallback +# --------------------------------------------------------------------------- + +def _jira_links_to_prs(jira_links: list) -> list[tuple[str, str, int]]: + matches: list[tuple[str, str, int]] = [] + for link in jira_links or []: + if not isinstance(link, dict): + continue + url = link.get("url") + if not url: + obj = link.get("object") + if isinstance(obj, dict): + url = obj.get("url") + if not url: + continue + match = _PR_URL_RE.match(url) + if match: + matches.append((match.group(1), match.group(2), int(match.group(3)))) + return matches + + +_GH_SEARCH_LIMIT = "100" + + +def _github_search_fallback( + owner: str, repo: str, issue_key: str +) -> tuple[list[tuple[str, str, int]], list[str]]: + """Search the current repo's PRs for `issue_key` in the title/body (`gh search prs`) and in + the branch name (`gh pr list`, since GitHub's search doesn't index branch names). Returns + (matches, warnings) — `warnings` records a gh call that failed or returned unparseable + output, so a real gh failure isn't silently indistinguishable from a genuine empty result.""" + matches: set[tuple[str, str, int]] = set() + warnings: list[str] = [] + + search_result = subprocess.run( + [ + "gh", "search", "prs", issue_key, + "--repo", f"{owner}/{repo}", + "--limit", _GH_SEARCH_LIMIT, + "--json", "number,url", + ], + capture_output=True, + text=True, + timeout=30, + ) + if search_result.returncode == 0: + try: + for item in json.loads(search_result.stdout): + number = item.get("number") + if number is not None: + matches.add((owner, repo, int(number))) + except json.JSONDecodeError: + warnings.append("gh search prs returned unexpected (non-JSON) output") + + list_result = subprocess.run( + [ + "gh", "pr", "list", + "--repo", f"{owner}/{repo}", + "--state", "all", + "--limit", _GH_SEARCH_LIMIT, + "--json", "number,headRefName", + ], + capture_output=True, + text=True, + timeout=30, + ) + if list_result.returncode == 0: + try: + for item in json.loads(list_result.stdout): + head_ref = item.get("headRefName") or "" + if issue_key in head_ref: + number = item.get("number") + if number is not None: + matches.add((owner, repo, int(number))) + except json.JSONDecodeError: + warnings.append("gh pr list returned unexpected (non-JSON) output") + + return list(matches), warnings + + +def _resolve_jira_work_item(ref: str, owner: str, repo: str, jira_links: list | None) -> dict: + if jira_links is None: + # No --jira-links flag was supplied at all (the skill's step 1 call) — distinct from an + # explicitly-empty list (step 2, after getJiraIssueRemoteIssueLinks returned nothing). + # Report a pending status instead of eagerly running the GitHub-search fallback. + return { + "status": "needs_jira_links", + "detail": ( + f"ref {ref!r} matched the jira provider's work-item-id pattern; call " + "getJiraIssueRemoteIssueLinks and re-run with --jira-links before this can resolve" + ), + } + pr_refs = _jira_links_to_prs(jira_links) + if pr_refs: + return _finalize_matches(pr_refs, source="work-item-jira-remote-link", ref=ref) + pr_refs, warnings = _github_search_fallback(owner, repo, ref) + result = _finalize_matches(pr_refs, source="work-item-jira-github-search", ref=ref) + if result["status"] == "not_found" and warnings: + result["detail"] += f" (gh warnings: {'; '.join(warnings)})" + return result + + +# --------------------------------------------------------------------------- +# GitHub work-item path: getLinkedPullRequests (gh api graphql) +# --------------------------------------------------------------------------- + +def _github_linked_prs(owner: str, repo: str, issue_number: int) -> list[tuple[str, str, int]]: + result = subprocess.run( + [ + "gh", "api", "graphql", + "-f", f"query={_LINKED_PRS_QUERY}", + "-F", f"owner={owner}", + "-F", f"repo={repo}", + "-F", f"number={issue_number}", + ], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + return [] + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + return [] + nodes = ( + (data.get("data") or {}).get("repository") or {} + ).get("issue") or {} + nodes = (nodes.get("closedByPullRequestsReferences") or {}).get("nodes") or [] + matches: list[tuple[str, str, int]] = [] + for node in nodes: + number = node.get("number") + if number is not None: + matches.append((owner, repo, int(number))) + return matches + + +def _resolve_github_work_item(ref: str, owner: str, repo: str) -> dict: + number_match = re.search(r"\d+", ref) + if not number_match: + return { + "status": "not_found", + "detail": f"could not extract a numeric issue number from GitHub work-item ref {ref!r}", + } + issue_number = int(number_match.group()) + pr_refs = _github_linked_prs(owner, repo, issue_number) + return _finalize_matches(pr_refs, source="work-item-github-linked-pr", ref=ref) + + +# --------------------------------------------------------------------------- +# Work-item dispatch: context file first, then provider-specific resolution +# --------------------------------------------------------------------------- + +def _resolve_work_item(ref: str, provider: str, jira_links: list | None) -> dict: + repo_slug = get_repo_slug() + + context_pr_url = _context_file_pr_url(ref, repo_slug) + if context_pr_url: + match = _PR_URL_RE.match(context_pr_url) + if not match: + return { + "status": "not_found", + "detail": f"context file for work item {ref!r} has a pr_url that does not match the expected GitHub PR URL format: {context_pr_url!r}", + } + owner, repo, number = match.group(1), match.group(2), int(match.group(3)) + return { + "status": "resolved", + "owner": owner, + "repo": repo, + "number": number, + "pr_url": context_pr_url, + "source": "work-item-context-file", + } + + owner, repo = _split_repo_slug(repo_slug) + if owner is None: + return { + "status": "not_found", + "detail": f"could not parse owner/repo from the current repo's git remote 'origin' to resolve work item {ref!r}", + } + + if provider == "jira": + return _resolve_jira_work_item(ref, owner, repo, jira_links) + if provider == "github": + return _resolve_github_work_item(ref, owner, repo) + return { + "status": "not_found", + "detail": f"ref {ref!r} matched provider {provider!r}'s work-item-id pattern, but no resolution logic is implemented for that provider", + } + + +# --------------------------------------------------------------------------- +# Top-level entry point +# --------------------------------------------------------------------------- + +def resolve_pr_reference(ref: str, jira_links: list | None = None) -> dict: + ref = (ref or "").strip() + if not ref: + return {"status": "not_found", "detail": "ref is empty; did not match any recognized format"} + + url_match = _PR_URL_RE.match(ref) + if url_match: + owner, repo, number = url_match.group(1), url_match.group(2), int(url_match.group(3)) + return _resolve_pr(owner, repo, number, source="url") + + bare_match = _BARE_NUMBER_RE.match(ref) + if bare_match: + return _resolve_bare_number(int(bare_match.group(1))) + + try: + config = build_merged_config() + except (YamlParseError, RuntimeError) as e: + return { + "status": "not_found", + "detail": f"ref {ref!r} did not match any recognized format, and project configuration could not be loaded to check work-item-id patterns: {e}", + } + + provider = _match_work_item_provider(ref, config.get("work-tracking") or {}) + if provider is None: + return { + "status": "not_found", + "detail": ( + f"ref {ref!r} did not match any recognized format: not a bare number, '#123', " + "full GitHub PR URL, or a work-item-id pattern configured for any provider" + ), + } + + return _resolve_work_item(ref, provider, jira_links) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("ref") + parser.add_argument("--jira-links", default=None) + args = parser.parse_args() + + jira_links = None + if args.jira_links is not None: + try: + jira_links = json.loads(args.jira_links) + except json.JSONDecodeError as e: + print(f"Error: --jira-links is not valid JSON: {e}", file=sys.stderr) + sys.exit(1) + + result = resolve_pr_reference(args.ref, jira_links=jira_links) + print(json.dumps(result), flush=True) + + +if __name__ == "__main__": + main() diff --git a/plugins/dev-team/skills/resolve-pr-reference/scripts/test_resolve_pr_reference.py b/plugins/dev-team/skills/resolve-pr-reference/scripts/test_resolve_pr_reference.py new file mode 100644 index 0000000..23f9844 --- /dev/null +++ b/plugins/dev-team/skills/resolve-pr-reference/scripts/test_resolve_pr_reference.py @@ -0,0 +1,1118 @@ +"""Tests for resolve_pr_reference.py — resolve_pr_reference() resolves a PR reference (a bare +number/`#123`, a full GitHub PR URL, or a work-item ID) to exactly one `owner/repo#number`, or a +structured not_found/ambiguous/access_denied failure. +""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent)) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def expect_gh_pr_view_succeeds(state: str = "OPEN") -> dict: + return {"returncode": 0, "stdout": json.dumps({"number": 1, "url": "x", "state": state}), "stderr": ""} + + +def make_fake_run(handlers): + """Build a subprocess.run side_effect that dispatches on a command-prefix -> handler map. + Each handler is a callable(cmd, **kwargs) -> MagicMock, or a dict of + {returncode, stdout, stderr} to wrap directly.""" + + def fake_run(cmd, **kwargs): + for prefix, handler in handlers.items(): + if tuple(cmd[: len(prefix)]) == prefix: + if callable(handler): + return handler(cmd, **kwargs) + return MagicMock( + returncode=handler.get("returncode", 0), + stdout=handler.get("stdout", ""), + stderr=handler.get("stderr", ""), + ) + raise AssertionError(f"unexpected command: {cmd}") + + return fake_run + + +JIRA_WORK_TRACKING = { + "jira": {"issue-key-pattern": "AIP-\\d+", "recognize-patterns": ["Task \\d+", "Jira \\d+"]}, + "github": {"issue-key-pattern": "Issue-\\d+", "recognize-patterns": ["#\\d+", "Issue \\d+"]}, +} + + +# --------------------------------------------------------------------------- +# Full GitHub PR URL — resolves independent of the current repo +# --------------------------------------------------------------------------- + +class TestResolvePrReferenceFullUrl: + def test_resolve_pr_reference_full_github_pr_url_resolves_independent_of_current_repo( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/other/repo.git") + from resolve_pr_reference import resolve_pr_reference + + fake_run = make_fake_run({("gh", "pr", "view"): expect_gh_pr_view_succeeds()}) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve_pr_reference(" https://github.com/acme/widget/pull/42 ") + + # Assert + assert result == { + "status": "resolved", + "owner": "acme", + "repo": "widget", + "number": 42, + "pr_url": "https://github.com/acme/widget/pull/42", + "source": "url", + } + + +# --------------------------------------------------------------------------- +# Bare PR number (or #123) — resolves against the current repo's origin +# --------------------------------------------------------------------------- + +class TestResolvePrReferenceBareNumber: + @pytest.mark.parametrize("ref", ["42", "#42"], ids=["bare_number", "hash_prefixed_number"]) + def test_resolve_pr_reference_bare_number_resolves_against_current_repo_origin( + self, tmp_path, monkeypatch, ref + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + from resolve_pr_reference import resolve_pr_reference + + fake_run = make_fake_run({("gh", "pr", "view"): expect_gh_pr_view_succeeds()}) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve_pr_reference(ref) + + # Assert + assert result == { + "status": "resolved", + "owner": "acme", + "repo": "widget", + "number": 42, + "pr_url": "https://github.com/acme/widget/pull/42", + "source": "number", + } + + def test_resolve_pr_reference_bare_number_unparseable_current_repo_slug_returns_not_found( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://gitlab.com/group/subgroup/repo.git") + from resolve_pr_reference import resolve_pr_reference + + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve_pr_reference("42") + + # Assert + assert result["status"] == "not_found" + assert "owner/repo" in result["detail"] + mock_run.assert_not_called() + + +# --------------------------------------------------------------------------- +# A ref matching none of the recognized shapes +# --------------------------------------------------------------------------- + +class TestResolvePrReferenceUnrecognizedShape: + @pytest.mark.parametrize( + "work_tracking", + [ + pytest.param({}, id="no_providers_configured"), + pytest.param(JIRA_WORK_TRACKING, id="providers_configured_but_no_match"), + ], + ) + def test_resolve_pr_reference_unrecognized_ref_returns_not_found_with_distinct_detail( + self, tmp_path, monkeypatch, work_tracking + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": work_tracking} + ) + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve("not-a-recognized-ref!!") + + # Assert + assert result["status"] == "not_found" + assert "did not match any recognized format" in result["detail"] + mock_run.assert_not_called() + + def test_resolve_pr_reference_empty_ref_returns_not_found(self, tmp_path, monkeypatch): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + from resolve_pr_reference import resolve_pr_reference + + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve_pr_reference(" ") + + # Assert + assert result == {"status": "not_found", "detail": "ref is empty; did not match any recognized format"} + mock_run.assert_not_called() + + def test_resolve_pr_reference_project_configuration_unreadable_returns_not_found( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + def raise_runtime_error(): + raise RuntimeError("could not locate repo root") + + monkeypatch.setattr(resolve_pr_reference, "build_merged_config", raise_runtime_error) + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve("AIP-18") + + # Assert + assert result["status"] == "not_found" + assert "project configuration could not be loaded" in result["detail"] + mock_run.assert_not_called() + + def test_resolve_pr_reference_malformed_provider_regex_pattern_is_skipped_not_raised( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + broken_work_tracking = {"jira": {"issue-key-pattern": "AIP-[", "recognize-patterns": []}} + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": broken_work_tracking} + ) + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve("AIP-18") + + # Assert + assert result["status"] == "not_found" + assert "did not match any recognized format" in result["detail"] + mock_run.assert_not_called() + + def test_resolve_pr_reference_work_item_provider_with_no_dispatch_logic_returns_not_found( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + gitlab_work_tracking = {"gitlab": {"issue-key-pattern": "GL-\\d+", "recognize-patterns": []}} + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": gitlab_work_tracking} + ) + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve("GL-9") + + # Assert + assert result["status"] == "not_found" + assert "gitlab" in result["detail"] + mock_run.assert_not_called() + + +# --------------------------------------------------------------------------- +# Work-item ID with an existing use-context-file context file whose pr_url is set +# --------------------------------------------------------------------------- + +class TestResolvePrReferenceWorkItemContextFile: + def test_resolve_pr_reference_work_item_with_context_file_pr_url_resolves_without_querying_provider( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + from dev_team import compute_context_path + from get_context_path import get_repo_slug + from pipeline_context import PipelineContext + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + path = compute_context_path("AIP-18", get_repo_slug()) + PipelineContext( + work_item_id="AIP-18", pr_url="https://github.com/acme/widget/pull/57" + ).save(path) + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve("AIP-18") + + # Assert + assert result == { + "status": "resolved", + "owner": "acme", + "repo": "widget", + "number": 57, + "pr_url": "https://github.com/acme/widget/pull/57", + "source": "work-item-context-file", + } + mock_run.assert_not_called() + + def test_resolve_pr_reference_context_file_malformed_pr_url_returns_not_found( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + from dev_team import compute_context_path + from get_context_path import get_repo_slug + from pipeline_context import PipelineContext + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + path = compute_context_path("AIP-18", get_repo_slug()) + PipelineContext(work_item_id="AIP-18", pr_url="not-a-url").save(path) + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve("AIP-18") + + # Assert + assert result["status"] == "not_found" + assert "pr_url" in result["detail"] + mock_run.assert_not_called() + + def test_resolve_pr_reference_context_file_exists_with_empty_pr_url_falls_through_to_provider( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + from dev_team import compute_context_path + from get_context_path import get_repo_slug + from pipeline_context import PipelineContext + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + path = compute_context_path("AIP-18", get_repo_slug()) + PipelineContext(work_item_id="AIP-18", pr_url="").save(path) + jira_links = [{"object": {"url": "https://github.com/acme/widget/pull/7"}}] + + # Act + with patch("subprocess.run", MagicMock()): + result = resolve("AIP-18", jira_links=jira_links) + + # Assert + assert result["status"] == "resolved" + assert result["source"] == "work-item-jira-remote-link" + assert result["number"] == 7 + + +# --------------------------------------------------------------------------- +# Jira work-item path — no --jira-links supplied yet (pending lookup) +# --------------------------------------------------------------------------- + +class TestResolvePrReferenceJiraLinksNotYetSupplied: + def test_resolve_pr_reference_jira_ref_with_no_jira_links_supplied_returns_needs_jira_links_without_running_fallback( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve("AIP-18") + + # Assert + assert result["status"] == "needs_jira_links" + assert "AIP-18" in result["detail"] + mock_run.assert_not_called() + + def test_resolve_pr_reference_jira_ref_with_context_file_pr_url_short_circuits_needs_jira_links( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + from dev_team import compute_context_path + from get_context_path import get_repo_slug + from pipeline_context import PipelineContext + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + path = compute_context_path("AIP-18", get_repo_slug()) + PipelineContext( + work_item_id="AIP-18", pr_url="https://github.com/acme/widget/pull/57" + ).save(path) + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve("AIP-18") + + # Assert — context file already resolved it, so no pending Jira lookup is needed + assert result["status"] == "resolved" + assert result["source"] == "work-item-context-file" + mock_run.assert_not_called() + + +# --------------------------------------------------------------------------- +# Jira work-item path — remote links +# --------------------------------------------------------------------------- + +class TestResolvePrReferenceJiraRemoteLinks: + def test_resolve_pr_reference_jira_single_remote_link_resolves_without_github_search( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + jira_links = [{"object": {"url": "https://github.com/acme/widget/pull/7"}}] + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve("AIP-18", jira_links=jira_links) + + # Assert + assert result == { + "status": "resolved", + "owner": "acme", + "repo": "widget", + "number": 7, + "pr_url": "https://github.com/acme/widget/pull/7", + "source": "work-item-jira-remote-link", + } + mock_run.assert_not_called() + + def test_resolve_pr_reference_jira_remote_link_url_field_at_top_level_also_matches( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + jira_links = [{"url": "https://github.com/acme/widget/pull/7"}] + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve("AIP-18", jira_links=jira_links) + + # Assert + assert result["status"] == "resolved" + assert result["number"] == 7 + + def test_resolve_pr_reference_jira_remote_links_non_pr_urls_are_ignored( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + jira_links = [ + {"object": {"url": "https://example.atlassian.net/wiki/page"}}, + {"not-a-dict": True}, + "not-even-a-dict", + ] + fake_run = make_fake_run( + { + ("gh", "search", "prs"): {"returncode": 0, "stdout": json.dumps([{"number": 9}])}, + ("gh", "pr", "list"): {"returncode": 0, "stdout": json.dumps([])}, + } + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve("AIP-18", jira_links=jira_links) + + # Assert + assert result["status"] == "resolved" + assert result["source"] == "work-item-jira-github-search" + assert result["number"] == 9 + + def test_resolve_pr_reference_jira_multiple_remote_links_returns_ambiguous_without_fallback( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + jira_links = [ + {"object": {"url": "https://github.com/acme/widget/pull/7"}}, + {"object": {"url": "https://github.com/acme/widget/pull/8"}}, + ] + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve("AIP-18", jira_links=jira_links) + + # Assert + assert result["status"] == "ambiguous" + assert "7" in result["detail"] and "8" in result["detail"] + mock_run.assert_not_called() + + +# --------------------------------------------------------------------------- +# Jira work-item path — GitHub-search fallback (remote links empty) +# --------------------------------------------------------------------------- + +class TestResolvePrReferenceJiraGithubSearchFallback: + def test_resolve_pr_reference_jira_no_remote_links_falls_back_to_github_search_title_match( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + fake_run = make_fake_run( + { + ("gh", "search", "prs"): {"returncode": 0, "stdout": json.dumps([{"number": 9}])}, + ("gh", "pr", "list"): {"returncode": 0, "stdout": json.dumps([])}, + } + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve("AIP-18", jira_links=[]) + + # Assert + assert result == { + "status": "resolved", + "owner": "acme", + "repo": "widget", + "number": 9, + "pr_url": "https://github.com/acme/widget/pull/9", + "source": "work-item-jira-github-search", + } + + def test_resolve_pr_reference_jira_no_remote_links_falls_back_to_github_search_branch_name_match( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + fake_run = make_fake_run( + { + ("gh", "search", "prs"): {"returncode": 0, "stdout": json.dumps([])}, + ("gh", "pr", "list"): { + "returncode": 0, + "stdout": json.dumps([{"number": 12, "headRefName": "dev/claude/AIP-18"}]), + }, + } + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve("AIP-18", jira_links=[]) + + # Assert + assert result["status"] == "resolved" + assert result["number"] == 12 + assert result["source"] == "work-item-jira-github-search" + + def test_resolve_pr_reference_jira_fallback_dedupes_same_pr_found_by_both_searches( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + fake_run = make_fake_run( + { + ("gh", "search", "prs"): {"returncode": 0, "stdout": json.dumps([{"number": 9}])}, + ("gh", "pr", "list"): { + "returncode": 0, + "stdout": json.dumps([{"number": 9, "headRefName": "dev/claude/AIP-18"}]), + }, + } + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve("AIP-18", jira_links=[]) + + # Assert + assert result["status"] == "resolved" + assert result["number"] == 9 + + def test_resolve_pr_reference_jira_fallback_finds_two_different_prs_returns_ambiguous( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + fake_run = make_fake_run( + { + ("gh", "search", "prs"): {"returncode": 0, "stdout": json.dumps([{"number": 9}])}, + ("gh", "pr", "list"): { + "returncode": 0, + "stdout": json.dumps([{"number": 11, "headRefName": "dev/claude/AIP-18"}]), + }, + } + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve("AIP-18", jira_links=[]) + + # Assert + assert result["status"] == "ambiguous" + assert "9" in result["detail"] and "11" in result["detail"] + + def test_resolve_pr_reference_jira_fallback_finds_nothing_returns_not_found( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + fake_run = make_fake_run( + { + ("gh", "search", "prs"): {"returncode": 0, "stdout": json.dumps([])}, + ("gh", "pr", "list"): {"returncode": 0, "stdout": json.dumps([])}, + } + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve("AIP-18", jira_links=[]) + + # Assert + assert result["status"] == "not_found" + assert "AIP-18" in result["detail"] + + def test_resolve_pr_reference_jira_fallback_gh_calls_fail_or_return_invalid_json_returns_not_found( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + fake_run = make_fake_run( + { + ("gh", "search", "prs"): {"returncode": 1, "stdout": "", "stderr": "rate limited"}, + ("gh", "pr", "list"): {"returncode": 0, "stdout": "not json"}, + } + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve("AIP-18", jira_links=[]) + + # Assert + assert result["status"] == "not_found" + assert "unexpected (non-JSON) output" in result["detail"] + + def test_resolve_pr_reference_jira_fallback_gh_calls_pass_explicit_limit_flag( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + seen_commands = [] + + def record_and_respond(cmd, **kwargs): + seen_commands.append(cmd) + if cmd[:3] == ["gh", "search", "prs"]: + return MagicMock(returncode=0, stdout=json.dumps([]), stderr="") + return MagicMock(returncode=0, stdout=json.dumps([]), stderr="") + + # Act + with patch("subprocess.run", side_effect=record_and_respond): + resolve("AIP-18", jira_links=[]) + + # Assert + search_cmd = next(cmd for cmd in seen_commands if cmd[:3] == ["gh", "search", "prs"]) + list_cmd = next(cmd for cmd in seen_commands if cmd[:3] == ["gh", "pr", "list"]) + assert "--limit" in search_cmd + assert "--limit" in list_cmd + + +# --------------------------------------------------------------------------- +# GitHub work-item path — getLinkedPullRequests (gh api graphql) +# --------------------------------------------------------------------------- + +class TestResolvePrReferenceGithubLinkedPr: + def test_resolve_pr_reference_github_work_item_single_linked_pr_resolves( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + graphql_response = { + "data": { + "repository": { + "issue": {"closedByPullRequestsReferences": {"nodes": [{"number": 5}]}} + } + } + } + fake_run = make_fake_run( + {("gh", "api", "graphql"): {"returncode": 0, "stdout": json.dumps(graphql_response)}} + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve("Issue-42") + + # Assert + assert result == { + "status": "resolved", + "owner": "acme", + "repo": "widget", + "number": 5, + "pr_url": "https://github.com/acme/widget/pull/5", + "source": "work-item-github-linked-pr", + } + + def test_resolve_pr_reference_github_work_item_zero_linked_prs_returns_not_found( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + graphql_response = { + "data": { + "repository": {"issue": {"closedByPullRequestsReferences": {"nodes": []}}} + } + } + fake_run = make_fake_run( + {("gh", "api", "graphql"): {"returncode": 0, "stdout": json.dumps(graphql_response)}} + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve("Issue-42") + + # Assert + assert result["status"] == "not_found" + + def test_resolve_pr_reference_github_work_item_multiple_linked_prs_returns_ambiguous( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + graphql_response = { + "data": { + "repository": { + "issue": { + "closedByPullRequestsReferences": {"nodes": [{"number": 5}, {"number": 6}]} + } + } + } + } + fake_run = make_fake_run( + {("gh", "api", "graphql"): {"returncode": 0, "stdout": json.dumps(graphql_response)}} + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve("Issue-42") + + # Assert + assert result["status"] == "ambiguous" + assert "5" in result["detail"] and "6" in result["detail"] + + def test_resolve_pr_reference_github_work_item_graphql_call_fails_returns_not_found( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + fake_run = make_fake_run( + {("gh", "api", "graphql"): {"returncode": 1, "stdout": "", "stderr": "boom"}} + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve("Issue-42") + + # Assert + assert result["status"] == "not_found" + + def test_resolve_pr_reference_github_work_item_graphql_returns_invalid_json_returns_not_found( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + fake_run = make_fake_run( + {("gh", "api", "graphql"): {"returncode": 0, "stdout": "not json"}} + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve("Issue-42") + + # Assert + assert result["status"] == "not_found" + + def test_resolve_pr_reference_github_work_item_ref_with_no_digits_returns_not_found( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + no_digit_work_tracking = {"github": {"issue-key-pattern": "gh-issue", "recognize-patterns": []}} + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": no_digit_work_tracking} + ) + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve("gh-issue") + + # Assert + assert result["status"] == "not_found" + assert "numeric issue number" in result["detail"] + + +# --------------------------------------------------------------------------- +# Work item — unparseable repo slug for work-item resolution (not just bare number) +# --------------------------------------------------------------------------- + +class TestResolvePrReferenceWorkItemUnparseableRepoSlug: + def test_resolve_pr_reference_work_item_unparseable_current_repo_slug_returns_not_found( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://gitlab.com/group/subgroup/repo.git") + import resolve_pr_reference + from resolve_pr_reference import resolve_pr_reference as resolve + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + result = resolve("AIP-18", jira_links=[]) + + # Assert + assert result["status"] == "not_found" + assert "owner/repo" in result["detail"] + mock_run.assert_not_called() + + +# --------------------------------------------------------------------------- +# gh pr view existence/access checks (shared by url and number paths) +# --------------------------------------------------------------------------- + +class TestResolvePrReferenceGhPrViewOutcomes: + def test_resolve_pr_reference_pr_does_not_exist_returns_not_found_with_reason( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + from resolve_pr_reference import resolve_pr_reference + + fake_run = make_fake_run( + { + ("gh", "pr", "view"): { + "returncode": 1, + "stdout": "", + "stderr": "GraphQL: Could not resolve to a PullRequest with the number of 42.", + } + } + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve_pr_reference("https://github.com/acme/widget/pull/42") + + # Assert + assert result["status"] == "not_found" + assert "42" in result["detail"] + + def test_resolve_pr_reference_pr_not_accessible_returns_access_denied_with_reason( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + from resolve_pr_reference import resolve_pr_reference + + fake_run = make_fake_run( + { + ("gh", "pr", "view"): { + "returncode": 1, + "stdout": "", + "stderr": "HTTP 403: Resource not accessible by integration", + } + } + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve_pr_reference("https://github.com/acme/widget/pull/42") + + # Assert + assert result["status"] == "access_denied" + assert "403" in result["detail"] + + def test_resolve_pr_reference_gh_pr_view_invalid_json_output_returns_not_found( + self, tmp_path, monkeypatch + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + from resolve_pr_reference import resolve_pr_reference + + fake_run = make_fake_run( + {("gh", "pr", "view"): {"returncode": 0, "stdout": "not json", "stderr": ""}} + ) + + # Act + with patch("subprocess.run", side_effect=fake_run): + result = resolve_pr_reference("https://github.com/acme/widget/pull/42") + + # Assert + assert result["status"] == "not_found" + + +# --------------------------------------------------------------------------- +# main() — CLI wrapper +# --------------------------------------------------------------------------- + +class TestMain: + def test_main_resolved_ref_prints_json_to_stdout_and_exits_zero( + self, tmp_path, monkeypatch, capsys + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import main + + monkeypatch.setattr(sys, "argv", ["resolve_pr_reference.py", "https://github.com/acme/widget/pull/42"]) + fake_run = make_fake_run({("gh", "pr", "view"): expect_gh_pr_view_succeeds()}) + + # Act + with patch("subprocess.run", side_effect=fake_run): + main() + + # Assert + captured = json.loads(capsys.readouterr().out) + assert captured["status"] == "resolved" + assert captured["number"] == 42 + + def test_main_jira_ref_without_jira_links_flag_prints_needs_jira_links_without_calling_gh( + self, tmp_path, monkeypatch, capsys + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import main + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + monkeypatch.setattr(sys, "argv", ["resolve_pr_reference.py", "AIP-18"]) + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + main() + + # Assert + captured = json.loads(capsys.readouterr().out) + assert captured["status"] == "needs_jira_links" + mock_run.assert_not_called() + + def test_main_with_jira_links_flag_passes_parsed_json_through( + self, tmp_path, monkeypatch, capsys + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + import resolve_pr_reference + from resolve_pr_reference import main + + monkeypatch.setattr( + resolve_pr_reference, "build_merged_config", lambda: {"work-tracking": JIRA_WORK_TRACKING} + ) + jira_links_json = json.dumps([{"object": {"url": "https://github.com/acme/widget/pull/7"}}]) + monkeypatch.setattr( + sys, "argv", ["resolve_pr_reference.py", "AIP-18", "--jira-links", jira_links_json] + ) + mock_run = MagicMock() + + # Act + with patch("subprocess.run", mock_run): + main() + + # Assert + captured = json.loads(capsys.readouterr().out) + assert captured["status"] == "resolved" + assert captured["number"] == 7 + assert captured["source"] == "work-item-jira-remote-link" + + def test_main_invalid_jira_links_json_exits_nonzero_with_error_message( + self, tmp_path, monkeypatch, capsys + ): + # Arrange + monkeypatch.setenv("DEV_TEAM_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("GIT_REMOTE_URL_OVERRIDE", "https://github.com/acme/widget.git") + from resolve_pr_reference import main + + monkeypatch.setattr( + sys, "argv", ["resolve_pr_reference.py", "AIP-18", "--jira-links", "not-json"] + ) + + # Act / Assert + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code != 0 + assert "Error" in capsys.readouterr().err diff --git a/plugins/dev-team/skills/work-with-GitHub-issues/SKILL.md b/plugins/dev-team/skills/work-with-GitHub-issues/SKILL.md index 4788c40..25eed3a 100644 --- a/plugins/dev-team/skills/work-with-GitHub-issues/SKILL.md +++ b/plugins/dev-team/skills/work-with-GitHub-issues/SKILL.md @@ -33,6 +33,22 @@ gh issue view | Write/update issue | `mcp__plugin_github_github__issue_write` | | Search issues | `mcp__plugin_github_github__search_issues` | | List issues | `mcp__plugin_github_github__list_issues` | +| Get linked pull requests | `getLinkedPullRequests` | + +`getLinkedPullRequests` is not an MCP tool — it's a plain `gh api graphql` call reading the +issue's `closedByPullRequestsReferences` connection, the same way `gh issue view ` above is a +CLI alternative to the MCP read tool: + +```bash +gh api graphql -f query=' + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + closedByPullRequestsReferences(first: 10) { nodes { number } } + } + } + }' -F owner= -F repo= -F number= +``` ## Issue number diff --git a/plugins/dev-team/skills/work-with-Jira-tasks/SKILL.md b/plugins/dev-team/skills/work-with-Jira-tasks/SKILL.md index 1fe687c..26818ee 100644 --- a/plugins/dev-team/skills/work-with-Jira-tasks/SKILL.md +++ b/plugins/dev-team/skills/work-with-Jira-tasks/SKILL.md @@ -67,6 +67,12 @@ caller's own failure contract; do not retry a second time. | Look up account ID by email | `lookupJiraAccountId` | Resolve a user's Jira account ID (and linked GitHub username, if any) from their email | | Get authenticated user info | `atlassianUserInfo` | Return the identity of the currently authenticated Atlassian user | | Link two issues | `createIssueLink` | Create a typed link (e.g. `Blocks`) between two issues | +| List remote issue links | `getJiraIssueRemoteIssueLinks` | List the issue's Remote Issue Links (e.g. linked PR URLs) | + +`getJiraIssueRemoteIssueLinks` hits Jira's generic Remote Issue Links API, not the Development +panel's separate dev-status API — no MCP tool connected in this environment exposes the +dev-status API, so a PR linked only via Smart Commits or branch-name convention (and never added +as an explicit remote link) won't show up here. Other skills should reference these operations by name (e.g. "the `editJiraIssue` operation from `work-with-Jira-tasks`") rather than hardcoding a `mcp____` tool name