Merge pull request 'feat: require issue type and workflow status labels (Closes #513)' (#518) from codex/issue-513-workflow-labels into master
This commit was merged in pull request #518.
This commit is contained in:
+246
-18
@@ -818,6 +818,7 @@ import root_checkout_guard # noqa: E402
|
||||
import remote_repo_guard # noqa: E402
|
||||
import issue_claim_heartbeat # noqa: E402
|
||||
import issue_work_duplicate_gate # noqa: E402
|
||||
import issue_workflow_labels # noqa: E402
|
||||
import reviewer_pr_lease # noqa: E402
|
||||
import merger_lease_adoption # noqa: E402
|
||||
import merged_cleanup_reconcile # noqa: E402
|
||||
@@ -1176,6 +1177,108 @@ def extract_linked_issue_numbers(text: str | None, branch_name: str | None = Non
|
||||
issues.update(int(m) for m in pattern.findall(branch_name))
|
||||
return sorted(list(issues))
|
||||
|
||||
def _repo_label_id_map(base: str, auth: str) -> dict[str, int]:
|
||||
labels = api_get_all(f"{base}/labels", auth) or []
|
||||
return {
|
||||
str(lb["name"]): int(lb["id"])
|
||||
for lb in labels
|
||||
if isinstance(lb, dict) and lb.get("name") and lb.get("id") is not None
|
||||
}
|
||||
|
||||
def _issue_label_names(base: str, auth: str, issue_number: int) -> list[str]:
|
||||
issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {}
|
||||
return issue_workflow_labels.label_names(issue)
|
||||
|
||||
def _put_issue_label_names(
|
||||
*,
|
||||
base: str,
|
||||
auth: str,
|
||||
issue_number: int,
|
||||
names: list[str],
|
||||
label_ids_by_name: dict[str, int] | None = None,
|
||||
) -> list[dict]:
|
||||
by_name = label_ids_by_name or _repo_label_id_map(base, auth)
|
||||
missing = [name for name in names if name not in by_name]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
f"The following labels do not exist on the repository: {missing}. "
|
||||
"Create the canonical workflow labels first."
|
||||
)
|
||||
ids = [by_name[name] for name in names]
|
||||
return api_request(
|
||||
"PUT",
|
||||
f"{base}/issues/{issue_number}/labels",
|
||||
auth,
|
||||
{"labels": ids},
|
||||
)
|
||||
|
||||
def _transition_issue_status(
|
||||
*,
|
||||
base: str,
|
||||
auth: str,
|
||||
issue_number: int,
|
||||
status: str,
|
||||
label_ids_by_name: dict[str, int] | None = None,
|
||||
) -> dict:
|
||||
by_name = label_ids_by_name or _repo_label_id_map(base, auth)
|
||||
target_status = issue_workflow_labels.canonical_status_label(status)
|
||||
if target_status not in by_name:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"issue_number": issue_number,
|
||||
"target_status": target_status,
|
||||
"reasons": [
|
||||
f"required workflow status label '{target_status}' does not exist"
|
||||
],
|
||||
}
|
||||
current = _issue_label_names(base, auth, issue_number)
|
||||
updated = issue_workflow_labels.transition_status_labels(current, target_status)
|
||||
_put_issue_label_names(
|
||||
base=base,
|
||||
auth=auth,
|
||||
issue_number=issue_number,
|
||||
names=updated,
|
||||
label_ids_by_name=by_name,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"issue_number": issue_number,
|
||||
"target_status": target_status,
|
||||
"labels": updated,
|
||||
}
|
||||
|
||||
def _prepare_issue_status_transition(
|
||||
*,
|
||||
base: str,
|
||||
auth: str,
|
||||
issue_number: int,
|
||||
status: str,
|
||||
) -> dict:
|
||||
by_name = _repo_label_id_map(base, auth)
|
||||
target_status = issue_workflow_labels.canonical_status_label(status)
|
||||
if target_status not in by_name:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"issue_number": issue_number,
|
||||
"target_status": target_status,
|
||||
"reasons": [
|
||||
f"required workflow status label '{target_status}' does not exist"
|
||||
],
|
||||
}
|
||||
current = _issue_label_names(base, auth, issue_number)
|
||||
updated = issue_workflow_labels.transition_status_labels(current, target_status)
|
||||
return {
|
||||
"success": True,
|
||||
"performed": False,
|
||||
"issue_number": issue_number,
|
||||
"target_status": target_status,
|
||||
"labels": updated,
|
||||
"label_ids_by_name": by_name,
|
||||
}
|
||||
|
||||
def release_in_progress_label(issue_numbers: list[int], remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
|
||||
if not issue_numbers:
|
||||
return {}
|
||||
@@ -1508,6 +1611,11 @@ def gitea_create_issue(
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
issue_type: str | None = None,
|
||||
initial_status: str | None = None,
|
||||
discussion: bool = False,
|
||||
require_workflow_labels: bool = False,
|
||||
allow_duplicate_override: bool = False,
|
||||
split_from_issue: int | None = None,
|
||||
worktree_path: str | None = None,
|
||||
@@ -1521,6 +1629,12 @@ def gitea_create_issue(
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
labels: Optional labels to apply by name after creating the issue.
|
||||
issue_type: Optional canonical type, e.g. 'feature' or 'type:feature'.
|
||||
initial_status: Optional initial workflow status, e.g. 'ready'.
|
||||
discussion: If True, require/apply type:discussion.
|
||||
require_workflow_labels: If True, fail closed unless labels include one
|
||||
type:* and one status:* label.
|
||||
allow_duplicate_override: Operator-approved split after duplicate found.
|
||||
split_from_issue: Existing duplicate issue number when overriding.
|
||||
worktree_path: Optional path to verify branches-only guard.
|
||||
@@ -1552,6 +1666,40 @@ def gitea_create_issue(
|
||||
return blocked
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue")
|
||||
base = repo_api_url(h, o, r)
|
||||
requested_labels = issue_workflow_labels.labels_for_new_issue(
|
||||
issue_type=issue_type,
|
||||
initial_status=initial_status,
|
||||
extra_labels=labels,
|
||||
discussion=discussion,
|
||||
)
|
||||
label_assessment = issue_workflow_labels.assess_issue_labels(
|
||||
requested_labels,
|
||||
discussion=discussion,
|
||||
require_type=True,
|
||||
require_status=True,
|
||||
)
|
||||
if require_workflow_labels and not label_assessment["valid"]:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"number": None,
|
||||
"workflow_label_validation": label_assessment,
|
||||
"reasons": label_assessment["errors"],
|
||||
}
|
||||
label_ids_by_name: dict[str, int] | None = None
|
||||
if requested_labels:
|
||||
label_ids_by_name = _repo_label_id_map(base, auth)
|
||||
missing = [name for name in requested_labels if name not in label_ids_by_name]
|
||||
if missing:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"number": None,
|
||||
"workflow_label_validation": label_assessment,
|
||||
"reasons": [
|
||||
f"requested labels do not exist on the repository: {missing}"
|
||||
],
|
||||
}
|
||||
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
|
||||
closed_issues = api_get_all(
|
||||
f"{base}/issues?state=closed&type=issues", auth, limit=100
|
||||
@@ -1586,7 +1734,29 @@ def gitea_create_issue(
|
||||
_audit("create_issue", host=h, remote=remote, org=o, repo=r,
|
||||
result=gitea_audit.SUCCEEDED, issue_number=data["number"],
|
||||
request_metadata={"title": title}, mutation_task="create_issue")
|
||||
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
|
||||
result = {
|
||||
"number": data["number"],
|
||||
"workflow_label_validation": label_assessment,
|
||||
}
|
||||
if requested_labels:
|
||||
with _audited(
|
||||
"set_issue_labels",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
issue_number=data["number"],
|
||||
request_metadata={"labels": requested_labels, "source": "create_issue"},
|
||||
):
|
||||
applied = _put_issue_label_names(
|
||||
base=base,
|
||||
auth=auth,
|
||||
issue_number=data["number"],
|
||||
names=requested_labels,
|
||||
label_ids_by_name=label_ids_by_name,
|
||||
)
|
||||
result["labels"] = issue_workflow_labels.label_names(applied)
|
||||
return _with_optional_url(result, data.get("html_url"))
|
||||
|
||||
|
||||
def _list_open_pulls(h: str, o: str, r: str, auth: str) -> list[dict]:
|
||||
@@ -1995,7 +2165,23 @@ def gitea_create_pr(
|
||||
)
|
||||
|
||||
auth = _auth(h)
|
||||
url = f"{repo_api_url(h, o, r)}/pulls"
|
||||
repo_base = repo_api_url(h, o, r)
|
||||
pr_open_transition = _prepare_issue_status_transition(
|
||||
base=repo_base,
|
||||
auth=auth,
|
||||
issue_number=int(locked_issue),
|
||||
status="status:pr-open",
|
||||
)
|
||||
if not pr_open_transition.get("success"):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"number": None,
|
||||
"issue_number": locked_issue,
|
||||
"issue_status_transition": pr_open_transition,
|
||||
"reasons": pr_open_transition.get("reasons", []),
|
||||
}
|
||||
url = f"{repo_base}/pulls"
|
||||
payload = {"title": title, "body": body, "head": head, "base": base}
|
||||
meta = {"title": title, "head": head, "base": base}
|
||||
try:
|
||||
@@ -2010,7 +2196,37 @@ def gitea_create_pr(
|
||||
result=gitea_audit.SUCCEEDED, pr_number=data["number"],
|
||||
target_branch=head, request_metadata=meta,
|
||||
mutation_task="create_pr")
|
||||
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
|
||||
with _audited(
|
||||
"set_issue_labels",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
issue_number=int(locked_issue),
|
||||
request_metadata={
|
||||
"source": "create_pr",
|
||||
"status": "status:pr-open",
|
||||
"pr_number": data["number"],
|
||||
},
|
||||
):
|
||||
_put_issue_label_names(
|
||||
base=repo_base,
|
||||
auth=auth,
|
||||
issue_number=int(locked_issue),
|
||||
names=pr_open_transition["labels"],
|
||||
label_ids_by_name=pr_open_transition["label_ids_by_name"],
|
||||
)
|
||||
result = {
|
||||
"number": data["number"],
|
||||
"issue_status_transition": {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"issue_number": int(locked_issue),
|
||||
"target_status": "status:pr-open",
|
||||
"labels": pr_open_transition["labels"],
|
||||
},
|
||||
}
|
||||
return _with_optional_url(result, data.get("html_url"))
|
||||
|
||||
|
||||
def _format_list_pr_entry(pr: dict) -> dict:
|
||||
@@ -8175,15 +8391,8 @@ def gitea_mark_issue(
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
|
||||
# Find the status:in-progress label id
|
||||
labels = api_request("GET", f"{base}/labels?limit=100", auth)
|
||||
label_id = None
|
||||
for lb in labels:
|
||||
if lb["name"] == "status:in-progress":
|
||||
label_id = lb["id"]
|
||||
break
|
||||
|
||||
if label_id is None:
|
||||
label_ids_by_name = _repo_label_id_map(base, auth)
|
||||
if "status:in-progress" not in label_ids_by_name:
|
||||
raise RuntimeError(
|
||||
"Label 'status:in-progress' not found. "
|
||||
"Run manage_labels.py to create it first."
|
||||
@@ -8191,10 +8400,19 @@ def gitea_mark_issue(
|
||||
|
||||
if action == "start":
|
||||
with _audited("label_issue", host=h, remote=remote, org=o, repo=r,
|
||||
issue_number=issue_number,
|
||||
request_metadata={"op": "add", "label": "status:in-progress"}):
|
||||
api_request("POST", f"{base}/issues/{issue_number}/labels", auth,
|
||||
{"labels": [label_id]})
|
||||
issue_number=issue_number, request_metadata={
|
||||
"op": "replace-status",
|
||||
"label": "status:in-progress",
|
||||
}):
|
||||
transition = _transition_issue_status(
|
||||
base=base,
|
||||
auth=auth,
|
||||
issue_number=issue_number,
|
||||
status="status:in-progress",
|
||||
label_ids_by_name=label_ids_by_name,
|
||||
)
|
||||
if not transition.get("success"):
|
||||
raise RuntimeError("; ".join(transition.get("reasons") or []))
|
||||
active_profile = (profile or get_profile().get("profile_name") or "unknown")
|
||||
branch = (branch_name or "pending").strip() or "pending"
|
||||
heartbeat_body = issue_claim_heartbeat.format_heartbeat_body(
|
||||
@@ -8219,8 +8437,10 @@ def gitea_mark_issue(
|
||||
"message": f"Issue #{issue_number} claimed.",
|
||||
"heartbeat_posted": heartbeat.get("success", False),
|
||||
"heartbeat_comment_id": heartbeat.get("comment_id"),
|
||||
"status_transition": transition,
|
||||
}
|
||||
else:
|
||||
label_id = label_ids_by_name["status:in-progress"]
|
||||
with _audited("unlabel_issue", host=h, remote=remote, org=o, repo=r,
|
||||
issue_number=issue_number,
|
||||
request_metadata={"op": "remove", "label": "status:in-progress"}):
|
||||
@@ -8637,6 +8857,7 @@ def gitea_create_label(
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a new label on a Gitea repository.
|
||||
|
||||
@@ -8648,11 +8869,16 @@ def gitea_create_label(
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
worktree_path: Optional branches worktree to verify before mutation.
|
||||
|
||||
Returns:
|
||||
dict containing the created label details.
|
||||
"""
|
||||
verify_preflight_purity(remote)
|
||||
blocked = _profile_permission_block(
|
||||
task_capability_map.required_permission("create_label"))
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_label")
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
@@ -8678,6 +8904,7 @@ def gitea_set_issue_labels(
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> list:
|
||||
"""Replace all labels on a Gitea issue with a new list of label names.
|
||||
|
||||
@@ -8688,6 +8915,7 @@ def gitea_set_issue_labels(
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
worktree_path: Optional branches worktree to verify before mutation.
|
||||
|
||||
Returns:
|
||||
list of all labels currently applied to the issue.
|
||||
@@ -8696,7 +8924,7 @@ def gitea_set_issue_labels(
|
||||
task_capability_map.required_permission("set_issue_labels"))
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote, task="set_issue_labels")
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="set_issue_labels")
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
|
||||
Reference in New Issue
Block a user