blob: 94ac2f3fc34eea6928eb52d33c4503214c79a914 [file]
"""GitHub CLI helper functions for the release tool."""
import abc
import enum
import json
import os
import re
import subprocess
import tempfile
from typing import (
TypedDict,
override, # pyrefly: ignore[missing-module-attribute] -- override available in Python 3.12+
)
from dev.release.release_issue import BackportTask, is_release_complete
from dev.release.shell import run_cmd
# GitHub label types
RELEASE_LABEL = "type: release"
BACKPORT_LABEL = "type: backport-pr"
RELEASE_PREPARED_LABEL = "release-prepared"
SYNC_CHANGELOG_LABEL = "type: sync-changelog"
# GitHub reaction types
# See: https://docs.github.com/en/rest/reactions/reactions?apiVersion=2022-11-28#about-reactions
GH_REACTION_THUMBS_UP = "+1"
GH_REACTION_THUMBS_DOWN = "-1"
GH_REACTION_LAUGH = "laugh"
GH_REACTION_CONFUSED = "confused"
GH_REACTION_HEART = "heart"
GH_REACTION_HOORAY = "hooray"
GH_REACTION_ROCKET = "rocket"
GH_REACTION_EYES = "eyes"
class BackportTaskStatus(str, enum.Enum):
"""Status strings for backport tasks on a release tracking issue."""
PENDING = "pending"
DONE = "done"
RESOLVED = "resolved"
OPEN_PR = "open-pr"
DRAFT_PR = "draft-pr"
ERROR_NOT_FOUND = "error-not-found"
ERROR_CLOSED_PR = "error-closed-pr"
ERROR_NO_MERGE_COMMIT = "error-no-merge-commit"
ERROR_UNKNOWN = "error-unknown"
ERROR_RESOLUTION_FAILED = "error-resolution-failed"
ERROR_MERGE_CONFLICT = "error-merge-conflict"
ERROR_INVALID_PR = "error-invalid-pr"
IGNORE = "ignore"
def __str__(self) -> str:
return self.value
class IssueDict(TypedDict, total=False):
"""In-memory representation of a GitHub Issue object.
See GitHub API docs:
https://docs.github.com/en/rest/issues/issues#get-an-issue
"""
number: int
title: str
body: str
labels: list[str]
url: str
class AutoMergeDict(TypedDict, total=False):
"""Representation of auto-merge status on a Pull Request.
See GitHub API docs:
https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request
"""
merge_method: str
class PrFileDict(TypedDict, total=False):
"""In-memory representation of a file in a GitHub Pull Request object.
See GitHub API docs:
https://docs.github.com/en/rest/pulls/pulls#list-pull-requests-files
"""
path: str
additions: int
deletions: int
changeType: str
class PrDict(TypedDict, total=False):
"""In-memory representation of a GitHub Pull Request object.
See GitHub API docs:
https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request
"""
number: int
title: str
body: str
base: str
head: str
labels: list[str]
url: str
state: str
isDraft: bool
mergeCommit: dict[str, str]
auto_merge: AutoMergeDict
files: list[PrFileDict]
class GitHubEventPullRequestDict(TypedDict, total=False):
"""Pull request object in a GitHub Actions event payload.
See GitHub Webhook events docs:
https://docs.github.com/en/webhooks/webhook-events-and-payloads#pull_request
"""
number: int
class GitHubEventIssueDict(TypedDict, total=False):
"""Issue object in a GitHub Actions event payload.
See GitHub Webhook events docs:
https://docs.github.com/en/webhooks/webhook-events-and-payloads#issues
"""
number: int
class GitHubEventDict(TypedDict, total=False):
"""Representation of a GitHub Actions event payload JSON ($GITHUB_EVENT_PATH).
See GitHub Actions events docs:
https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows
"""
inputs: dict[str, object]
pull_request: GitHubEventPullRequestDict
issue: GitHubEventIssueDict
number: int | None
def get_github_event_data() -> GitHubEventDict:
"""Loads JSON data from GITHUB_EVENT_PATH if set."""
event_path = os.environ.get("GITHUB_EVENT_PATH")
if not event_path or not os.path.isfile(event_path):
return {}
with open(event_path, "r", encoding="utf-8") as f:
return json.load(f)
def get_github_event_pr_number() -> int | None:
"""Extracts PR number from GITHUB_EVENT_PATH if available."""
data = get_github_event_data()
if not data:
return None
if (
"inputs" in data
and isinstance(data["inputs"], dict)
and data["inputs"].get("pr")
):
pr_val = str(data["inputs"]["pr"]).lstrip("#")
if pr_val.isdigit():
return int(pr_val)
if (
"pull_request" in data
and isinstance(data["pull_request"], dict)
and data["pull_request"].get("number")
):
return int(data["pull_request"]["number"])
if "number" in data and isinstance(data["number"], int):
return data["number"]
return None
def get_github_event_issue_number() -> int | None:
"""Extracts Issue number from GITHUB_EVENT_PATH if available."""
data = get_github_event_data()
if not data:
return None
if (
"inputs" in data
and isinstance(data["inputs"], dict)
and data["inputs"].get("issue")
):
issue_val = str(data["inputs"]["issue"]).lstrip("#")
if issue_val.isdigit():
return int(issue_val)
if (
"issue" in data
and isinstance(data["issue"], dict)
and data["issue"].get("number")
):
return int(data["issue"]["number"])
return None
class MultipleTrackingIssuesError(ValueError):
"""Raised when multiple open tracking issues are found for a version."""
pass
class NoTrackingIssueError(ValueError):
"""Raised when no open tracking issue is found for a version."""
pass
class CreatePrError(Exception):
"""Raised when creating a pull request fails."""
pass
class GetPrError(ValueError):
"""Raised when querying a pull request fails."""
pass
class InvalidPrRefError(ValueError):
"""Raised when a PR reference cannot be resolved."""
pass
def format_complete_issue_warning(issue: IssueDict) -> str:
"""Formats the warning for an open tracking issue whose release is complete.
Args:
issue: The open release tracking issue being ignored.
Returns:
A one-line message, without any `::warning::` prefix.
"""
return (
f"Ignoring open release tracking issue #{issue['number']}"
f" ({issue['title']}): its 'Tag Final' task is done, so the release is"
" complete. Consider closing it."
)
class GitHubInterface(abc.ABC):
"""Abstract interface for GitHub operations."""
repo: str
@abc.abstractmethod
def post_issue_comment(self, issue_num: int, comment_body: str) -> None:
"""Posts a comment on an issue or PR.
Args:
issue_num: The issue or PR number.
comment_body: The body content of the comment.
"""
@abc.abstractmethod
def add_comment_reaction(self, comment_id: int, reaction: str) -> None:
"""Adds a reaction to an issue or PR comment.
Args:
comment_id: The comment ID.
reaction: The reaction type (e.g., "+1", "-1", "rocket").
"""
@abc.abstractmethod
def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None:
"""Enables auto-merge for a PR.
Args:
pr_num: The PR number.
method: The merge method ('squash', 'rebase', or 'merge').
"""
@abc.abstractmethod
def create_issue(
self, title: str, body: str, labels: list[str] | None = None
) -> int:
"""Creates an issue.
Args:
title: Title of the issue.
body: Body text of the issue.
labels: Optional list of labels to add.
Returns:
The created issue number.
"""
@abc.abstractmethod
def create_release_tracking_issue(self, version: str, template_content: str) -> int:
"""Creates a release tracking issue from a template.
Args:
version: Release version string (e.g., "1.0.0").
template_content: Content of the issue template markdown file.
Returns:
The created issue number.
"""
@abc.abstractmethod
def get_issue_body(self, issue_num: int) -> str:
"""Gets the body content of an issue.
Args:
issue_num: The issue number.
Returns:
The body string of the issue.
"""
@abc.abstractmethod
def get_issue_title(self, issue_num: int) -> str:
"""Gets the title of an issue.
Args:
issue_num: The issue number.
Returns:
The title string of the issue.
"""
@abc.abstractmethod
def update_issue_body(self, issue_num: int, body: str) -> None:
"""Updates the body of an issue.
Args:
issue_num: The issue number.
body: The new body content.
"""
@abc.abstractmethod
def resolve_pr_number(self, pr_ref: str) -> int:
"""Resolves a PR reference (number, #number, or GitHub URL) to a PR number.
Args:
pr_ref: PR number string (e.g., "123", "#123") or URL.
Returns:
The integer PR number.
Raises:
InvalidPrRefError: If the PR reference cannot be resolved or is for
another repository.
"""
@abc.abstractmethod
def get_release_tracking_issue(self, version: str) -> int:
"""Finds the single open tracking issue for a given version.
Args:
version: Version string (e.g., "1.0.0").
Returns:
The issue number.
Raises:
NoTrackingIssueError: If no open tracking issue is found.
MultipleTrackingIssuesError: If multiple open tracking issues are
found.
"""
@abc.abstractmethod
def create_pr(
self,
title: str,
body: str,
base: str = "main",
labels: list[str] | None = None,
) -> str:
"""Creates a pull request.
Args:
title: Title of the PR.
body: Body of the PR.
base: Base branch to merge into (default: "main").
labels: Optional list of labels to add.
Returns:
The URL of the created PR.
"""
@abc.abstractmethod
def get_open_pr(self, branch_name: str) -> PrDict | None:
"""Finds an open PR for the given branch.
Args:
branch_name: The head branch name to search for.
Returns:
Dictionary containing PR details if open, else None.
"""
@abc.abstractmethod
def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict]:
"""Finds open tracking issues for release.
Args:
version: Optional specific version to match (e.g., "1.0.0").
Returns:
List of matching open release tracking issue dictionaries.
"""
def partition_open_tracking_issues(
self,
) -> tuple[list[IssueDict], list[IssueDict]]:
"""Splits open release tracking issues into active and complete ones.
An open tracking issue whose release has already been tagged (its
"Tag Final" task is done) is complete; it just hasn't been closed.
Commands looking for the release currently in progress should only
consider the active issues, and may want to warn about the complete
ones so someone closes them.
Returns:
A tuple of (active, complete) lists of open release tracking
issue dictionaries.
"""
active: list[IssueDict] = []
complete: list[IssueDict] = []
for issue in self.get_open_tracking_issues():
body = self.get_issue_body(issue["number"])
if is_release_complete(body):
complete.append(issue)
else:
active.append(issue)
return active, complete
@abc.abstractmethod
def get_pr_info(self, pr_num: int) -> PrDict:
"""Gets info about a PR.
Args:
pr_num: The PR number.
Returns:
Dictionary containing PR fields (state, isDraft, mergeCommit, etc.).
Raises:
GetPrError: If querying the PR fails.
"""
@abc.abstractmethod
def get_pr_files(self, pr_num: int) -> list[str]:
"""Gets the list of file paths touched by a PR.
Args:
pr_num: The PR number.
Returns:
A list of file paths.
Raises:
GetPrError: If querying the PR fails.
"""
@abc.abstractmethod
def get_pr_comments(self, pr_num: int) -> list[dict]:
"""Gets all comments for a PR.
Args:
pr_num: The PR number.
Returns:
List of comment objects.
Raises:
GetPrError: If querying the PR fails.
"""
@abc.abstractmethod
def get_merge_commits_for_prs(
self, pending_items: list[BackportTask]
) -> list[BackportTask]:
"""Resolves PR references in pending backports to their merge commit SHAs.
Args:
pending_items: A list of BackportTask items to resolve.
Returns:
The list of resolved BackportTask items.
"""
class GitHub(GitHubInterface):
"""GitHub CLI helper class for the release tool."""
def __init__(self, repo: str = "bazel-contrib/rules_python"):
"""Initializes the GitHub helper.
Args:
repo: The GitHub repository to operate on.
"""
self.repo = repo
def _run_gh(
self, *args: str, check: bool = True, capture_output: bool = True
) -> str | None:
"""Runs a 'gh' command.
Args:
*args: Arguments for 'gh' (excluding 'gh').
check: If True, raises CalledProcessError on failure.
capture_output: If True, captures and returns stdout.
Returns:
The stdout of the command, stripped, or None.
"""
return run_cmd("gh", *args, check=check, capture_output=capture_output)
def _gh_issue(
self, *args: str, check: bool = True, capture_output: bool = True
) -> str | None:
"""Runs a 'gh issue' command."""
return self._run_gh(
"issue",
*args,
f"--repo={self.repo}",
check=check,
capture_output=capture_output,
)
def _gh_pr(
self, *args: str, check: bool = True, capture_output: bool = True
) -> str | None:
"""Runs a 'gh pr' command."""
return self._run_gh(
"pr",
*args,
f"--repo={self.repo}",
check=check,
capture_output=capture_output,
)
def list_issues(
self,
*,
fields: str,
label: str | None = None,
state: str | None = None,
search: str | None = None,
) -> list[IssueDict]:
"""Helper to list issues using gh CLI.
Args:
fields: Comma-separated list of fields to return.
label: Filter by label.
state: Filter by state ('open', 'closed', 'all').
search: Search query.
Returns:
A list of issue dictionaries.
"""
cmd = ["list", f"--json={fields}"]
if label:
cmd.append(f"--label={label}")
if state:
cmd.append(f"--state={state}")
if search:
cmd.append(f"--search={search}")
output = self._gh_issue(*cmd)
return json.loads(output) if output else []
@override
def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict]:
"""Finds open tracking issues for release.
Args:
version: Optional specific version to match (e.g., "1.0.0").
Returns:
List of matching open release tracking issue dictionaries.
"""
search = f"Release {version}" if version else None
return self.list_issues(
fields="number,title,url",
label=RELEASE_LABEL,
state="open",
search=search,
)
@override
def get_release_tracking_issue(self, version: str) -> int:
"""Finds the single open tracking issue for a given version.
Args:
version: Version string (e.g. "1.0.0").
Returns:
The issue number.
Raises:
NoTrackingIssueError: If no open tracking issue is found.
MultipleTrackingIssuesError: If multiple open tracking issues are found.
"""
issues = self.get_open_tracking_issues(version)
matching = [i for i in issues if i["title"] == f"Release {version}"]
if not matching:
raise NoTrackingIssueError(
f"No open tracking issue found for Release {version}"
)
if len(matching) > 1:
raise MultipleTrackingIssuesError(
f"Multiple open tracking issues found for Release {version}: "
+ ", ".join(str(i["number"]) for i in matching)
)
return matching[0]["number"]
@override
def create_issue(
self, title: str, body: str, labels: list[str] | None = None
) -> int:
"""Creates an issue using gh CLI.
Args:
title: Title of the issue.
body: Body of the issue.
labels: List of labels to add.
Returns:
The issue number.
"""
cmd = ["create", f"--title={title}", f"--body={body}"]
if labels:
for label in labels:
cmd.append(f"--label={label}")
output = self._gh_issue(*cmd)
if not output:
raise RuntimeError("gh issue create returned no output")
# output is URL: https://github.com/owner/repo/issues/123
return int(output.rstrip("/").split("/")[-1])
@override
def create_release_tracking_issue(self, version: str, template_content: str) -> int:
"""Creates a release tracking issue from a template.
Args:
version: Release version string (e.g., "1.0.0").
template_content: Content of the issue template markdown file.
Returns:
The created issue number.
"""
title = f"Release {version}"
# Strip YAML frontmatter if present
issue_body = template_content
if template_content.startswith("---"):
parts = template_content.split("---", 2)
if len(parts) >= 3:
issue_body = parts[2].strip()
return self.create_issue(title=title, body=issue_body, labels=[RELEASE_LABEL])
@override
def get_issue_body(self, issue_num: int) -> str:
"""Gets the body content of an issue.
Args:
issue_num: The issue number.
Returns:
The body string of the issue.
"""
output = self._gh_issue("view", str(issue_num), "--json=body")
if not output:
return ""
data = json.loads(output)
return data.get("body", "")
@override
def get_issue_title(self, issue_num: int) -> str:
"""Gets the title of an issue.
Args:
issue_num: The issue number.
Returns:
The title string of the issue.
"""
output = self._gh_issue("view", str(issue_num), "--json=title")
if not output:
return ""
data = json.loads(output)
return data.get("title", "")
@override
def update_issue_body(self, issue_num: int, body: str) -> None:
"""Updates the body of an issue.
Args:
issue_num: The issue number.
body: The new body content.
"""
with tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf-8") as f:
f.write(body)
f.flush()
temp_path = f.name
try:
self._gh_issue(
"edit", str(issue_num), f"--body-file={temp_path}", capture_output=False
)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
@override
def resolve_pr_number(self, pr_ref: str) -> int:
"""Resolves a PR reference (number, #number, or GitHub URL) to a PR number.
Args:
pr_ref: PR number string (e.g., "123", "#123") or URL.
Returns:
The integer PR number.
Raises:
InvalidPrRefError: If the PR reference cannot be resolved or is for another repo.
"""
clean_ref = pr_ref.lstrip("#")
if clean_ref.isdigit():
return int(clean_ref)
if pr_ref.startswith("http"):
pattern = rf"github\.com/{re.escape(self.repo)}/pull/(\d+)(/|\?|\Z)"
match = re.search(pattern, pr_ref, re.IGNORECASE)
if match:
return int(match.group(1))
raise InvalidPrRefError(
f"URL is not for the configured repository ({self.repo}): {pr_ref}"
)
raise InvalidPrRefError(f"Could not resolve PR reference: {pr_ref}")
def _gh_pr_view(self, pr_num: int, *fields: str) -> str:
"""Helper to run `gh pr view` with specified JSON fields.
Args:
pr_num: The PR number.
*fields: JSON fields to request (e.g., "state", "files").
Returns:
The raw JSON output string from gh.
Raises:
GetPrError: If querying the PR fails.
"""
args = ["view", str(pr_num)]
if fields:
args.append(f"--json={','.join(fields)}")
try:
output = self._gh_pr(*args)
return output or ""
except subprocess.CalledProcessError as e:
raise GetPrError(f"Failed to get PR #{pr_num} on {self.repo}: {e}") from e
@override
def get_pr_info(self, pr_num: int) -> PrDict:
"""Gets info about a PR using gh CLI.
Args:
pr_num: The PR number.
Returns:
Dictionary containing PR fields (state, isDraft, mergeCommit, body, etc.).
"""
output = self._gh_pr_view(pr_num, "state", "isDraft", "mergeCommit", "body")
return json.loads(output) if output else {}
@override
def get_pr_files(self, pr_num: int) -> list[str]:
"""Gets the list of file paths touched by a PR using gh CLI.
Args:
pr_num: The PR number.
Returns:
A list of file paths.
Raises:
GetPrError: If querying the PR fails.
"""
output = self._gh_pr_view(pr_num, "files")
if not output:
return []
data: PrDict = json.loads(output)
files = data.get("files", [])
return [f["path"] for f in files]
@override
def get_pr_comments(self, pr_num: int) -> list[dict]:
"""Gets all comments for a PR using gh CLI.
Args:
pr_num: The PR number.
Returns:
List of comment objects (with body, author, etc.).
"""
output = self._gh_pr_view(pr_num, "comments")
if not output:
return []
data = json.loads(output)
return data.get("comments", [])
@override
def create_pr(
self,
title: str,
body: str,
base: str = "main",
labels: list[str] | None = None,
) -> str:
"""Creates a pull request.
Args:
title: Title of the PR.
body: Body of the PR.
base: Base branch to merge into (default: "main").
labels: Optional list of labels to add.
Returns:
The URL of the created PR.
Raises:
CreatePrError: If creating the pull request fails.
"""
cmd = [
"create",
f"--title={title}",
f"--body={body}",
f"--base={base}",
]
if labels:
for label in labels:
cmd.append(f"--label={label}")
try:
output = self._gh_pr(*cmd)
except subprocess.CalledProcessError as e:
msg = f"Failed to create PR '{title}': {e}"
if e.stdout:
msg += (
f"\n{'=' * 20} STDOUT BEGIN {'=' * 20}\n"
f"{e.stdout}\n"
f"{'=' * 20} STDOUT END {'=' * 20}"
)
if e.stderr:
msg += (
f"\n{'=' * 20} STDERR BEGIN {'=' * 20}\n"
f"{e.stderr}\n"
f"{'=' * 20} STDERR END {'=' * 20}"
)
raise CreatePrError(msg) from e
except Exception as e:
raise CreatePrError(f"Failed to create PR '{title}': {e}") from e
if not output:
raise CreatePrError(
f"Failed to create PR '{title}': gh pr create returned no output"
)
return output
@override
def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None:
"""Enables auto-merge for a PR.
Args:
pr_num: The PR number.
method: The merge method ('squash', 'rebase', or 'merge').
"""
cmd = ["merge", str(pr_num), "--auto"]
if method == "squash":
cmd.append("--squash")
elif method == "rebase":
cmd.append("--rebase")
elif method == "merge":
cmd.append("--merge")
self._gh_pr(*cmd, capture_output=False)
@override
def get_open_pr(self, branch_name: str) -> PrDict | None:
"""Finds an open PR for the given branch.
Args:
branch_name: The head branch name to search for.
Returns:
Dictionary with 'number' and 'url' if an open PR exists, else None.
"""
cmd = [
"list",
f"--head={branch_name}",
"--state=open",
"--json=number,url",
]
output = self._gh_pr(*cmd)
prs = json.loads(output) if output else []
return prs[0] if prs else None
@override
def post_issue_comment(self, issue_num: int, comment_body: str) -> None:
"""Posts a comment on an issue or PR.
Args:
issue_num: The issue or PR number.
comment_body: The body content of the comment.
"""
self._gh_issue(
"comment",
str(issue_num),
f"--body={comment_body}",
capture_output=False,
)
@override
def add_comment_reaction(self, comment_id: int, reaction: str) -> None:
"""Adds a reaction to an issue or PR comment.
Args:
comment_id: The comment ID (note: gh api endpoint needed for comment reactions).
reaction: The reaction type (e.g., "+1", "-1", "rocket").
"""
self._run_gh(
"api",
f"repos/{self.repo}/issues/comments/{comment_id}/reactions",
"-f",
f"content={reaction}",
capture_output=False,
)
@override
def get_merge_commits_for_prs(
self, pending_items: list[BackportTask]
) -> list[BackportTask]:
"""Resolves PR references in pending backports to their merge commit SHAs.
Updates item.status based on PR state if it cannot be resolved.
Args:
pending_items: A list of BackportTask items to resolve.
Returns:
The list of resolved BackportTask items.
"""
return resolve_merge_commits_for_prs(self, pending_items)
def resolve_merge_commits_for_prs(
gh_client: GitHubInterface, pending_items: list[BackportTask]
) -> list[BackportTask]:
"""Resolves PR references in pending backports to their merge commit SHAs.
Updates item.status based on PR state if it cannot be resolved.
Args:
gh_client: The GitHub client.
pending_items: A list of BackportTask items to resolve.
Returns:
The list of resolved BackportTask items.
"""
resolved_items = []
for item in pending_items:
pr_num = int(item.pr_ref.lstrip("#"))
print(f"Resolving PR #{pr_num} to merge commit...")
try:
pr_info = gh_client.get_pr_info(pr_num)
if not pr_info:
print(f"PR #{pr_num} not found. Gating.")
item.status = BackportTaskStatus.ERROR_NOT_FOUND
else:
state = pr_info.get("state")
is_draft = pr_info.get("isDraft", False)
if state == "OPEN" or is_draft:
print(
f"PR #{pr_num} is open or draft (state: {state},"
f" draft: {is_draft}). Ignoring."
)
item.status = (
BackportTaskStatus.OPEN_PR
if not is_draft
else BackportTaskStatus.DRAFT_PR
)
elif state == "CLOSED":
print(f"PR #{pr_num} is closed but not merged. Gating.")
item.status = BackportTaskStatus.ERROR_CLOSED_PR
elif state == "MERGED":
merge_commit = pr_info.get("mergeCommit")
if merge_commit and "oid" in merge_commit:
item.commit = merge_commit["oid"]
item.status = BackportTaskStatus.RESOLVED
else:
print(f"PR #{pr_num} has no merge commit SHA. Gating.")
item.status = BackportTaskStatus.ERROR_NO_MERGE_COMMIT
else:
print(f"PR #{pr_num} has unknown state: {state}. Gating.")
item.status = BackportTaskStatus.ERROR_UNKNOWN
except Exception as e:
print(f"Error resolving PR #{pr_num}: {e}. Gating.")
item.status = BackportTaskStatus.ERROR_RESOLUTION_FAILED
resolved_items.append(item)
return resolved_items