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:
2026-07-09 07:44:54 -05:00
12 changed files with 838 additions and 73 deletions
+246 -18
View File
@@ -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)