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:
+53
-1
@@ -29,6 +29,7 @@ from gitea_auth import (
|
|||||||
get_credentials, resolve_remote, add_remote_args,
|
get_credentials, resolve_remote, add_remote_args,
|
||||||
api_request, repo_api_url,
|
api_request, repo_api_url,
|
||||||
)
|
)
|
||||||
|
import issue_workflow_labels
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
@@ -38,6 +39,16 @@ def main(argv=None):
|
|||||||
parser.add_argument("--body", default="", help="Issue body text.")
|
parser.add_argument("--body", default="", help="Issue body text.")
|
||||||
parser.add_argument("--body-file",
|
parser.add_argument("--body-file",
|
||||||
help="Read issue body from this file ('-' for stdin).")
|
help="Read issue body from this file ('-' for stdin).")
|
||||||
|
parser.add_argument("--label", action="append", default=[],
|
||||||
|
help="Existing label name to apply; may be repeated.")
|
||||||
|
parser.add_argument("--type-label",
|
||||||
|
help="Issue type, e.g. feature or type:feature.")
|
||||||
|
parser.add_argument("--status-label",
|
||||||
|
help="Initial status, e.g. ready or status:ready.")
|
||||||
|
parser.add_argument("--discussion", action="store_true",
|
||||||
|
help="Apply type:discussion.")
|
||||||
|
parser.add_argument("--require-workflow-labels", action="store_true",
|
||||||
|
help="Fail unless one type:* and one status:* label are supplied.")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
host, org, repo = resolve_remote(args)
|
host, org, repo = resolve_remote(args)
|
||||||
@@ -50,6 +61,24 @@ def main(argv=None):
|
|||||||
with open(args.body_file, "r", encoding="utf-8") as fh:
|
with open(args.body_file, "r", encoding="utf-8") as fh:
|
||||||
body = fh.read()
|
body = fh.read()
|
||||||
|
|
||||||
|
requested_labels = issue_workflow_labels.labels_for_new_issue(
|
||||||
|
issue_type=args.type_label,
|
||||||
|
initial_status=args.status_label,
|
||||||
|
extra_labels=args.label,
|
||||||
|
discussion=args.discussion,
|
||||||
|
)
|
||||||
|
label_assessment = issue_workflow_labels.assess_issue_labels(
|
||||||
|
requested_labels,
|
||||||
|
discussion=args.discussion,
|
||||||
|
)
|
||||||
|
if args.require_workflow_labels and not label_assessment["valid"]:
|
||||||
|
print(
|
||||||
|
"Workflow label validation failed: "
|
||||||
|
+ "; ".join(label_assessment["errors"]),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
user, password = get_credentials(host)
|
user, password = get_credentials(host)
|
||||||
if not user or not password:
|
if not user or not password:
|
||||||
print(f"Could not get credentials for {host} "
|
print(f"Could not get credentials for {host} "
|
||||||
@@ -59,11 +88,34 @@ def main(argv=None):
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
auth = f"Basic {base64.b64encode(f'{user}:{password}'.encode()).decode()}"
|
auth = f"Basic {base64.b64encode(f'{user}:{password}'.encode()).decode()}"
|
||||||
url = f"{repo_api_url(host, org, repo)}/issues"
|
base = repo_api_url(host, org, repo)
|
||||||
|
url = f"{base}/issues"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
label_ids = []
|
||||||
|
if requested_labels:
|
||||||
|
existing = api_request("GET", f"{base}/labels", auth)
|
||||||
|
by_name = {lb["name"]: lb["id"] for lb in existing}
|
||||||
|
missing = [name for name in requested_labels if name not in by_name]
|
||||||
|
if missing:
|
||||||
|
print(f"Missing labels: {missing}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
label_ids = [by_name[name] for name in requested_labels]
|
||||||
data = api_request("POST", url, auth, {"title": args.title, "body": body})
|
data = api_request("POST", url, auth, {"title": args.title, "body": body})
|
||||||
|
if label_ids:
|
||||||
|
api_request(
|
||||||
|
"PUT",
|
||||||
|
f"{base}/issues/{data.get('number')}/labels",
|
||||||
|
auth,
|
||||||
|
{"labels": label_ids},
|
||||||
|
)
|
||||||
print(f"Issue #{data.get('number')}: {data.get('html_url')}")
|
print(f"Issue #{data.get('number')}: {data.get('html_url')}")
|
||||||
|
if not label_assessment["valid"]:
|
||||||
|
print(
|
||||||
|
"Workflow label recommendation: "
|
||||||
|
+ "; ".join(label_assessment["errors"]),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
print(f"Error: {e}", file=sys.stderr)
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
|||||||
+88
-24
@@ -1,34 +1,98 @@
|
|||||||
# Label Taxonomy
|
# Label Taxonomy
|
||||||
|
|
||||||
This document catalogs the issue labels used for MCP workflows, including Jenkins and GlitchTip (observability).
|
This document defines the canonical issue labels used by MCP workflows.
|
||||||
|
|
||||||
> **Approval Required:** Do not create or apply new labels in `manage_labels.py` without explicit owner approval of this document.
|
Every issue should carry:
|
||||||
|
|
||||||
## Existing Labels
|
- one `type:*` label
|
||||||
|
- one `status:*` label
|
||||||
|
|
||||||
* **`jenkins`**
|
Discussion-only issues must carry `type:discussion`.
|
||||||
* Description: Jenkins integration
|
|
||||||
* Color: `d93f0b`
|
|
||||||
* Use: Used to mark issues, PRs, or tasks that involve the `jenkins-mcp` boundaries, CI/CD designs, or build failures.
|
|
||||||
|
|
||||||
* **`glitchtip`**
|
## Issue Type Labels
|
||||||
* Description: GlitchTip integration
|
|
||||||
* Color: `b60205`
|
|
||||||
* Use: Used to mark issues related to the `glitchtip-mcp` boundary and observability integration.
|
|
||||||
|
|
||||||
## Proposed / Missing Labels
|
| Label | Use |
|
||||||
|
| --- | --- |
|
||||||
|
| `type:bug` | Bug or defect |
|
||||||
|
| `type:feature` | Feature or enhancement |
|
||||||
|
| `type:process` | Process or policy work |
|
||||||
|
| `type:workflow` | Workflow automation or guidance |
|
||||||
|
| `type:guardrail` | Safety gate or guardrail |
|
||||||
|
| `type:docs` | Documentation work |
|
||||||
|
| `type:test` | Tests or test infrastructure |
|
||||||
|
| `type:discussion` | Discussion-only issue |
|
||||||
|
| `type:umbrella` | Umbrella or tracker issue |
|
||||||
|
| `type:cleanup` | Cleanup or hygiene work |
|
||||||
|
|
||||||
* **`observability`**
|
## Workflow Status Labels
|
||||||
* Proposed Description: Observability, metrics, and monitoring tasks
|
|
||||||
* Proposed Color: `5319e7`
|
|
||||||
* Use: Broader than GlitchTip alone; covers logging, metrics, traces, and general observability pipeline improvements.
|
|
||||||
|
|
||||||
* **`source:glitchtip`**
|
Only one `status:*` label should be active on an issue at a time. When an issue
|
||||||
* Proposed Description: Issue filed automatically by GlitchTip orchestration
|
moves forward, tooling must remove the old `status:*` label and apply the new
|
||||||
* Proposed Color: `b60205`
|
one.
|
||||||
* Use: Applied automatically by the orchestrator when a GlitchTip error event is converted into a Gitea issue.
|
|
||||||
|
|
||||||
* **`status:triage`**
|
| Label | Use |
|
||||||
* Proposed Description: Issue needs human or orchestrator triage
|
| --- | --- |
|
||||||
* Proposed Color: `fbca04`
|
| `status:triage` | Issue needs triage |
|
||||||
* Use: Used for incoming issues (especially automated ones like `source:glitchtip`) that have not yet been evaluated for priority or resolution.
|
| `status:ready` | Issue is ready for work |
|
||||||
|
| `status:claimed` | Issue is claimed |
|
||||||
|
| `status:in-progress` | Issue is being worked on |
|
||||||
|
| `status:blocked` | Issue is blocked |
|
||||||
|
| `status:needs-review` | Issue work needs review |
|
||||||
|
| `status:pr-open` | A linked PR is open |
|
||||||
|
| `status:approved` | Linked PR is approved |
|
||||||
|
| `status:merged` | Linked PR is merged |
|
||||||
|
| `status:reconcile` | Issue needs reconciliation |
|
||||||
|
| `status:done` | Issue workflow is complete |
|
||||||
|
| `status:duplicate` | Issue is a duplicate |
|
||||||
|
| `status:wontfix` | Issue will not be fixed |
|
||||||
|
|
||||||
|
## Transition Rules
|
||||||
|
|
||||||
|
Suggested lifecycle:
|
||||||
|
|
||||||
|
1. New issue created: `status:triage` or `status:ready`
|
||||||
|
2. Issue selected by an author: `status:claimed`
|
||||||
|
3. Author starts work: `status:in-progress`
|
||||||
|
4. Work is blocked: `status:blocked`
|
||||||
|
5. PR opened: `status:pr-open`
|
||||||
|
6. PR approved: `status:approved`
|
||||||
|
7. PR merged but issue still needs closure/reconciliation: `status:reconcile`
|
||||||
|
8. Issue fully complete: `status:done`
|
||||||
|
9. Duplicate issue: `status:duplicate`
|
||||||
|
10. Won't-fix issue: `status:wontfix`
|
||||||
|
|
||||||
|
The helper module `issue_workflow_labels.py` is the source of truth for the
|
||||||
|
canonical label specs and status transition replacement behavior.
|
||||||
|
|
||||||
|
## Discussion Issues
|
||||||
|
|
||||||
|
Discussion issues must be labeled `type:discussion`.
|
||||||
|
|
||||||
|
A discussion issue should not be treated as implementation-ready unless it also
|
||||||
|
has a clear implementation status and next action.
|
||||||
|
|
||||||
|
If a discussion produces implementation work, either:
|
||||||
|
|
||||||
|
1. convert the discussion issue into an implementation issue by changing labels
|
||||||
|
and adding acceptance criteria, or
|
||||||
|
2. create child implementation issues and leave the discussion issue as
|
||||||
|
`type:discussion`.
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
|
||||||
|
- `manage_labels.py --create-labels` creates the canonical `type:*` and
|
||||||
|
`status:*` labels.
|
||||||
|
- `gitea_create_issue` recommends `type:*` and `status:*` labels when missing
|
||||||
|
and can apply supplied label names.
|
||||||
|
- `gitea_mark_issue(..., action="start")` replaces old `status:*` labels with
|
||||||
|
`status:in-progress`.
|
||||||
|
- `gitea_create_pr` fails closed before PR creation if `status:pr-open` cannot
|
||||||
|
be applied to the locked issue, then applies it after the PR is created.
|
||||||
|
- `gitea_set_issue_labels` accepts an explicit `worktree_path` so author
|
||||||
|
sessions can satisfy the branches-only mutation guard while changing labels.
|
||||||
|
|
||||||
|
## Existing Non-Workflow Labels
|
||||||
|
|
||||||
|
Existing non-workflow labels such as `mcp`, `workflow`, `labels`, `tracker`,
|
||||||
|
`jenkins`, `glitchtip`, `documentation`, and `testing` remain valid topical
|
||||||
|
labels. They do not replace the required `type:*` and `status:*` labels.
|
||||||
|
|||||||
@@ -634,19 +634,24 @@ loop and do **not** substitute WebFetch/Playwright/manual base64.
|
|||||||
- **Profile:** issue-manager or author (any profile allowed to create issues).
|
- **Profile:** issue-manager or author (any profile allowed to create issues).
|
||||||
- **Steps:** create the parent/roadmap issue; create child issues; apply the
|
- **Steps:** create the parent/roadmap issue; create child issues; apply the
|
||||||
minimal label set; link children to the parent.
|
minimal label set; link children to the parent.
|
||||||
|
- **Labels:** new issues should carry one `type:*` label and one `status:*`
|
||||||
|
label. Discussion-only issues must carry `type:discussion`. See
|
||||||
|
[`label-taxonomy.md`](label-taxonomy.md).
|
||||||
- **Prompt:** `Using the issue-manager profile, create issue "<title>" with body
|
- **Prompt:** `Using the issue-manager profile, create issue "<title>" with body
|
||||||
<body>, then create child issues for <list> and link them to the parent.`
|
<body>, then create child issues for <list> and link them to the parent.`
|
||||||
|
|
||||||
### Implement an issue and open a PR
|
### Implement an issue and open a PR
|
||||||
|
|
||||||
- **Profile:** author.
|
- **Profile:** author.
|
||||||
- **Steps:** claim the issue (`status:in-progress`); create an isolated branch
|
- **Steps:** claim the issue (`status:in-progress`, replacing any old
|
||||||
worktree from latest `master` under `branches/` (`feat/issue-<n>-...` /
|
`status:*` label); create an isolated branch worktree from latest `master`
|
||||||
|
under `branches/` (`feat/issue-<n>-...` /
|
||||||
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
|
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
|
||||||
update tests if behavior changes; run the full suite; commit with an
|
update tests if behavior changes; run the full suite; commit with an
|
||||||
issue-linked message; open a PR to `master`. **Do not** review or merge your
|
issue-linked message; open a PR to `master`; move the issue to
|
||||||
own PR. Include an `LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in
|
`status:pr-open`. **Do not** review or merge your own PR. Include an
|
||||||
the PR body — see [`llm-agent-sha.md`](llm-agent-sha.md).
|
`LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in the PR body — see
|
||||||
|
[`llm-agent-sha.md`](llm-agent-sha.md).
|
||||||
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
|
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
|
||||||
master. Do not self-review or self-merge.`
|
master. Do not self-review or self-merge.`
|
||||||
|
|
||||||
|
|||||||
+246
-18
@@ -818,6 +818,7 @@ import root_checkout_guard # noqa: E402
|
|||||||
import remote_repo_guard # noqa: E402
|
import remote_repo_guard # noqa: E402
|
||||||
import issue_claim_heartbeat # noqa: E402
|
import issue_claim_heartbeat # noqa: E402
|
||||||
import issue_work_duplicate_gate # noqa: E402
|
import issue_work_duplicate_gate # noqa: E402
|
||||||
|
import issue_workflow_labels # noqa: E402
|
||||||
import reviewer_pr_lease # noqa: E402
|
import reviewer_pr_lease # noqa: E402
|
||||||
import merger_lease_adoption # noqa: E402
|
import merger_lease_adoption # noqa: E402
|
||||||
import merged_cleanup_reconcile # 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))
|
issues.update(int(m) for m in pattern.findall(branch_name))
|
||||||
return sorted(list(issues))
|
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:
|
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:
|
if not issue_numbers:
|
||||||
return {}
|
return {}
|
||||||
@@ -1508,6 +1611,11 @@ def gitea_create_issue(
|
|||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: 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,
|
allow_duplicate_override: bool = False,
|
||||||
split_from_issue: int | None = None,
|
split_from_issue: int | None = None,
|
||||||
worktree_path: str | None = None,
|
worktree_path: str | None = None,
|
||||||
@@ -1521,6 +1629,12 @@ def gitea_create_issue(
|
|||||||
host: Override the Gitea host.
|
host: Override the Gitea host.
|
||||||
org: Override the owner/organization.
|
org: Override the owner/organization.
|
||||||
repo: Override the repository name.
|
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.
|
allow_duplicate_override: Operator-approved split after duplicate found.
|
||||||
split_from_issue: Existing duplicate issue number when overriding.
|
split_from_issue: Existing duplicate issue number when overriding.
|
||||||
worktree_path: Optional path to verify branches-only guard.
|
worktree_path: Optional path to verify branches-only guard.
|
||||||
@@ -1552,6 +1666,40 @@ def gitea_create_issue(
|
|||||||
return blocked
|
return blocked
|
||||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue")
|
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue")
|
||||||
base = repo_api_url(h, o, r)
|
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)
|
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
|
||||||
closed_issues = api_get_all(
|
closed_issues = api_get_all(
|
||||||
f"{base}/issues?state=closed&type=issues", auth, limit=100
|
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,
|
_audit("create_issue", host=h, remote=remote, org=o, repo=r,
|
||||||
result=gitea_audit.SUCCEEDED, issue_number=data["number"],
|
result=gitea_audit.SUCCEEDED, issue_number=data["number"],
|
||||||
request_metadata={"title": title}, mutation_task="create_issue")
|
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]:
|
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)
|
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}
|
payload = {"title": title, "body": body, "head": head, "base": base}
|
||||||
meta = {"title": title, "head": head, "base": base}
|
meta = {"title": title, "head": head, "base": base}
|
||||||
try:
|
try:
|
||||||
@@ -2010,7 +2196,37 @@ def gitea_create_pr(
|
|||||||
result=gitea_audit.SUCCEEDED, pr_number=data["number"],
|
result=gitea_audit.SUCCEEDED, pr_number=data["number"],
|
||||||
target_branch=head, request_metadata=meta,
|
target_branch=head, request_metadata=meta,
|
||||||
mutation_task="create_pr")
|
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:
|
def _format_list_pr_entry(pr: dict) -> dict:
|
||||||
@@ -8175,15 +8391,8 @@ def gitea_mark_issue(
|
|||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
base = repo_api_url(h, o, r)
|
base = repo_api_url(h, o, r)
|
||||||
|
|
||||||
# Find the status:in-progress label id
|
label_ids_by_name = _repo_label_id_map(base, auth)
|
||||||
labels = api_request("GET", f"{base}/labels?limit=100", auth)
|
if "status:in-progress" not in label_ids_by_name:
|
||||||
label_id = None
|
|
||||||
for lb in labels:
|
|
||||||
if lb["name"] == "status:in-progress":
|
|
||||||
label_id = lb["id"]
|
|
||||||
break
|
|
||||||
|
|
||||||
if label_id is None:
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Label 'status:in-progress' not found. "
|
"Label 'status:in-progress' not found. "
|
||||||
"Run manage_labels.py to create it first."
|
"Run manage_labels.py to create it first."
|
||||||
@@ -8191,10 +8400,19 @@ def gitea_mark_issue(
|
|||||||
|
|
||||||
if action == "start":
|
if action == "start":
|
||||||
with _audited("label_issue", host=h, remote=remote, org=o, repo=r,
|
with _audited("label_issue", host=h, remote=remote, org=o, repo=r,
|
||||||
issue_number=issue_number,
|
issue_number=issue_number, request_metadata={
|
||||||
request_metadata={"op": "add", "label": "status:in-progress"}):
|
"op": "replace-status",
|
||||||
api_request("POST", f"{base}/issues/{issue_number}/labels", auth,
|
"label": "status:in-progress",
|
||||||
{"labels": [label_id]})
|
}):
|
||||||
|
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")
|
active_profile = (profile or get_profile().get("profile_name") or "unknown")
|
||||||
branch = (branch_name or "pending").strip() or "pending"
|
branch = (branch_name or "pending").strip() or "pending"
|
||||||
heartbeat_body = issue_claim_heartbeat.format_heartbeat_body(
|
heartbeat_body = issue_claim_heartbeat.format_heartbeat_body(
|
||||||
@@ -8219,8 +8437,10 @@ def gitea_mark_issue(
|
|||||||
"message": f"Issue #{issue_number} claimed.",
|
"message": f"Issue #{issue_number} claimed.",
|
||||||
"heartbeat_posted": heartbeat.get("success", False),
|
"heartbeat_posted": heartbeat.get("success", False),
|
||||||
"heartbeat_comment_id": heartbeat.get("comment_id"),
|
"heartbeat_comment_id": heartbeat.get("comment_id"),
|
||||||
|
"status_transition": transition,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
label_id = label_ids_by_name["status:in-progress"]
|
||||||
with _audited("unlabel_issue", host=h, remote=remote, org=o, repo=r,
|
with _audited("unlabel_issue", host=h, remote=remote, org=o, repo=r,
|
||||||
issue_number=issue_number,
|
issue_number=issue_number,
|
||||||
request_metadata={"op": "remove", "label": "status:in-progress"}):
|
request_metadata={"op": "remove", "label": "status:in-progress"}):
|
||||||
@@ -8637,6 +8857,7 @@ def gitea_create_label(
|
|||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new label on a Gitea repository.
|
"""Create a new label on a Gitea repository.
|
||||||
|
|
||||||
@@ -8648,11 +8869,16 @@ def gitea_create_label(
|
|||||||
host: Override the Gitea host.
|
host: Override the Gitea host.
|
||||||
org: Override the owner/organization.
|
org: Override the owner/organization.
|
||||||
repo: Override the repository name.
|
repo: Override the repository name.
|
||||||
|
worktree_path: Optional branches worktree to verify before mutation.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict containing the created label details.
|
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)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
base = repo_api_url(h, o, r)
|
base = repo_api_url(h, o, r)
|
||||||
@@ -8678,6 +8904,7 @@ def gitea_set_issue_labels(
|
|||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Replace all labels on a Gitea issue with a new list of label names.
|
"""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.
|
host: Override the Gitea host.
|
||||||
org: Override the owner/organization.
|
org: Override the owner/organization.
|
||||||
repo: Override the repository name.
|
repo: Override the repository name.
|
||||||
|
worktree_path: Optional branches worktree to verify before mutation.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list of all labels currently applied to the issue.
|
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"))
|
task_capability_map.required_permission("set_issue_labels"))
|
||||||
if blocked:
|
if blocked:
|
||||||
return 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)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
base = repo_api_url(h, o, r)
|
base = repo_api_url(h, o, r)
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Canonical issue type/status label taxonomy and transition helpers (#513)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable, Mapping, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LabelSpec:
|
||||||
|
name: str
|
||||||
|
color: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
TYPE_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||||
|
LabelSpec("type:bug", "b60205", "Bug or defect"),
|
||||||
|
LabelSpec("type:feature", "a2eeef", "Feature or enhancement"),
|
||||||
|
LabelSpec("type:process", "5319e7", "Process or policy work"),
|
||||||
|
LabelSpec("type:workflow", "c2e0c6", "Workflow automation or guidance"),
|
||||||
|
LabelSpec("type:guardrail", "d93f0b", "Safety gate or guardrail"),
|
||||||
|
LabelSpec("type:docs", "006b75", "Documentation work"),
|
||||||
|
LabelSpec("type:test", "0e8a16", "Tests or test infrastructure"),
|
||||||
|
LabelSpec("type:discussion", "bfdadc", "Discussion-only issue"),
|
||||||
|
LabelSpec("type:umbrella", "c5def5", "Umbrella or tracker issue"),
|
||||||
|
LabelSpec("type:cleanup", "ededed", "Cleanup or hygiene work"),
|
||||||
|
)
|
||||||
|
|
||||||
|
STATUS_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||||
|
LabelSpec("status:triage", "fbca04", "Issue needs triage"),
|
||||||
|
LabelSpec("status:ready", "0e8a16", "Issue is ready for work"),
|
||||||
|
LabelSpec("status:claimed", "fefe2e", "Issue is claimed"),
|
||||||
|
LabelSpec("status:in-progress", "fefe2e", "Issue is being worked on"),
|
||||||
|
LabelSpec("status:blocked", "b60205", "Issue is blocked"),
|
||||||
|
LabelSpec("status:needs-review", "0052cc", "Issue work needs review"),
|
||||||
|
LabelSpec("status:pr-open", "1d76db", "A linked PR is open"),
|
||||||
|
LabelSpec("status:approved", "0e8a16", "Linked PR is approved"),
|
||||||
|
LabelSpec("status:merged", "5319e7", "Linked PR is merged"),
|
||||||
|
LabelSpec("status:reconcile", "d93f0b", "Issue needs reconciliation"),
|
||||||
|
LabelSpec("status:done", "0e8a16", "Issue workflow is complete"),
|
||||||
|
LabelSpec("status:duplicate", "cccccc", "Issue is a duplicate"),
|
||||||
|
LabelSpec("status:wontfix", "000000", "Issue will not be fixed"),
|
||||||
|
)
|
||||||
|
|
||||||
|
CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||||
|
TYPE_LABEL_SPECS + STATUS_LABEL_SPECS
|
||||||
|
)
|
||||||
|
|
||||||
|
TYPE_LABELS: frozenset[str] = frozenset(spec.name for spec in TYPE_LABEL_SPECS)
|
||||||
|
STATUS_LABELS: frozenset[str] = frozenset(spec.name for spec in STATUS_LABEL_SPECS)
|
||||||
|
CANONICAL_LABELS: frozenset[str] = frozenset(
|
||||||
|
spec.name for spec in CANONICAL_LABEL_SPECS
|
||||||
|
)
|
||||||
|
|
||||||
|
STATUS_TRANSITIONS: dict[str, str] = {
|
||||||
|
"triage": "status:triage",
|
||||||
|
"ready": "status:ready",
|
||||||
|
"claim": "status:claimed",
|
||||||
|
"claimed": "status:claimed",
|
||||||
|
"start": "status:in-progress",
|
||||||
|
"start_work": "status:in-progress",
|
||||||
|
"in_progress": "status:in-progress",
|
||||||
|
"in-progress": "status:in-progress",
|
||||||
|
"block": "status:blocked",
|
||||||
|
"blocked": "status:blocked",
|
||||||
|
"needs_review": "status:needs-review",
|
||||||
|
"needs-review": "status:needs-review",
|
||||||
|
"pr_open": "status:pr-open",
|
||||||
|
"pr-open": "status:pr-open",
|
||||||
|
"approved": "status:approved",
|
||||||
|
"merge": "status:reconcile",
|
||||||
|
"merged": "status:reconcile",
|
||||||
|
"reconcile": "status:reconcile",
|
||||||
|
"done": "status:done",
|
||||||
|
"complete": "status:done",
|
||||||
|
"duplicate": "status:duplicate",
|
||||||
|
"wontfix": "status:wontfix",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def label_name(label: str | Mapping[str, object]) -> str:
|
||||||
|
"""Return a normalized label name from a Gitea label object or string."""
|
||||||
|
if isinstance(label, str):
|
||||||
|
return label.strip()
|
||||||
|
value = label.get("name")
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def label_names(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||||
|
"""Extract label names from a label list or issue-like mapping."""
|
||||||
|
raw: object
|
||||||
|
if isinstance(labels, Mapping):
|
||||||
|
raw = labels.get("labels", [])
|
||||||
|
else:
|
||||||
|
raw = labels
|
||||||
|
return [name for name in (label_name(label) for label in raw or []) if name]
|
||||||
|
|
||||||
|
|
||||||
|
def type_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||||
|
return [name for name in label_names(labels) if name.startswith("type:")]
|
||||||
|
|
||||||
|
|
||||||
|
def status_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||||
|
return [name for name in label_names(labels) if name.startswith("status:")]
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_status_label(status_or_transition: str) -> str:
|
||||||
|
status = status_or_transition.strip()
|
||||||
|
if status in STATUS_LABELS:
|
||||||
|
return status
|
||||||
|
normalized = status.lower().replace(" ", "-")
|
||||||
|
try:
|
||||||
|
return STATUS_TRANSITIONS[normalized]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"unknown workflow status or transition '{status_or_transition}'"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def transition_status_labels(
|
||||||
|
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||||
|
status_or_transition: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Replace all active status labels with the requested canonical status."""
|
||||||
|
new_status = canonical_status_label(status_or_transition)
|
||||||
|
kept = [name for name in label_names(existing_labels) if not name.startswith("status:")]
|
||||||
|
if new_status not in kept:
|
||||||
|
kept.append(new_status)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def labels_for_new_issue(
|
||||||
|
issue_type: str | None = None,
|
||||||
|
initial_status: str | None = None,
|
||||||
|
extra_labels: Sequence[str] | None = None,
|
||||||
|
*,
|
||||||
|
discussion: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
names = list(extra_labels or [])
|
||||||
|
if issue_type:
|
||||||
|
type_name = issue_type if issue_type.startswith("type:") else f"type:{issue_type}"
|
||||||
|
names.append(type_name)
|
||||||
|
if discussion:
|
||||||
|
names.append("type:discussion")
|
||||||
|
if initial_status:
|
||||||
|
names.append(canonical_status_label(initial_status))
|
||||||
|
|
||||||
|
result: list[str] = []
|
||||||
|
for name in names:
|
||||||
|
if name not in result:
|
||||||
|
result.append(name)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def assess_issue_labels(
|
||||||
|
labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||||
|
*,
|
||||||
|
discussion: bool = False,
|
||||||
|
require_type: bool = True,
|
||||||
|
require_status: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
names = label_names(labels)
|
||||||
|
found_types = type_labels(names)
|
||||||
|
found_statuses = status_labels(names)
|
||||||
|
errors: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if require_type and not found_types:
|
||||||
|
errors.append("issue is missing a type:* label")
|
||||||
|
if require_status and not found_statuses:
|
||||||
|
errors.append("issue is missing a status:* label")
|
||||||
|
if discussion and "type:discussion" not in found_types:
|
||||||
|
errors.append("discussion issue is missing type:discussion")
|
||||||
|
if len(found_statuses) > 1:
|
||||||
|
errors.append(
|
||||||
|
"issue has multiple active status:* labels: "
|
||||||
|
+ ", ".join(found_statuses)
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in found_types:
|
||||||
|
if name not in TYPE_LABELS:
|
||||||
|
warnings.append(f"unknown type label '{name}'")
|
||||||
|
for name in found_statuses:
|
||||||
|
if name not in STATUS_LABELS:
|
||||||
|
warnings.append(f"unknown status label '{name}'")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": not errors,
|
||||||
|
"labels": names,
|
||||||
|
"type_labels": found_types,
|
||||||
|
"status_labels": found_statuses,
|
||||||
|
"errors": errors,
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
+5
-2
@@ -23,6 +23,7 @@ if os.path.exists(venv_python) and sys.executable != venv_python:
|
|||||||
os.execv(venv_python, [venv_python] + sys.argv)
|
os.execv(venv_python, [venv_python] + sys.argv)
|
||||||
|
|
||||||
from gitea_auth import get_auth_header, api_request, repo_api_url
|
from gitea_auth import get_auth_header, api_request, repo_api_url
|
||||||
|
import issue_workflow_labels
|
||||||
|
|
||||||
HOST = "gitea.dadeschools.net"
|
HOST = "gitea.dadeschools.net"
|
||||||
ORG = "Contractor"
|
ORG = "Contractor"
|
||||||
@@ -35,8 +36,10 @@ LABELS = [
|
|||||||
{"name": "epic", "color": "8250df", "description": ""},
|
{"name": "epic", "color": "8250df", "description": ""},
|
||||||
{"name": "important", "color": "fbca04", "description": ""},
|
{"name": "important", "color": "fbca04", "description": ""},
|
||||||
{"name": "nice-to-have", "color": "0e8a16", "description": ""},
|
{"name": "nice-to-have", "color": "0e8a16", "description": ""},
|
||||||
{"name": "status:in-progress", "color": "fefe2e",
|
*[
|
||||||
"description": "Issue is being worked on"},
|
{"name": spec.name, "color": spec.color, "description": spec.description}
|
||||||
|
for spec in issue_workflow_labels.CANONICAL_LABEL_SPECS
|
||||||
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
# issue number -> label names to apply (one-off backfill)
|
# issue number -> label names to apply (one-off backfill)
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.issue.comment",
|
"permission": "gitea.issue.comment",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
"create_label": {
|
||||||
|
"permission": "gitea.issue.comment",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
"create_branch": {
|
"create_branch": {
|
||||||
"permission": "gitea.branch.create",
|
"permission": "gitea.branch.create",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
@@ -163,6 +167,7 @@ ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
|||||||
"gitea_create_issue_comment": "comment_issue",
|
"gitea_create_issue_comment": "comment_issue",
|
||||||
"gitea_mark_issue": "mark_issue",
|
"gitea_mark_issue": "mark_issue",
|
||||||
"gitea_set_issue_labels": "set_issue_labels",
|
"gitea_set_issue_labels": "set_issue_labels",
|
||||||
|
"gitea_create_label": "create_label",
|
||||||
"gitea_commit_files": "commit_files",
|
"gitea_commit_files": "commit_files",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,37 @@ class TestAPIPayload(unittest.TestCase):
|
|||||||
self.assertEqual(payload["title"], "My Title")
|
self.assertEqual(payload["title"], "My Title")
|
||||||
self.assertEqual(payload["body"], "My Body")
|
self.assertEqual(payload["body"], "My Body")
|
||||||
|
|
||||||
|
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||||
|
def test_applies_workflow_labels_by_name(self, _cred):
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "GET" and url.endswith("/labels"):
|
||||||
|
return [
|
||||||
|
{"id": 1, "name": "type:feature"},
|
||||||
|
{"id": 2, "name": "status:ready"},
|
||||||
|
]
|
||||||
|
if method == "POST" and url.endswith("/issues"):
|
||||||
|
return {"number": 7, "html_url": "http://x/7"}
|
||||||
|
if method == "PUT" and url.endswith("/issues/7/labels"):
|
||||||
|
return [{"name": "type:feature"}, {"name": "status:ready"}]
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with patch("create_issue.api_request", side_effect=api_side_effect) as mock_api:
|
||||||
|
rc = create_issue.main([
|
||||||
|
"--title", "My Title",
|
||||||
|
"--type-label", "feature",
|
||||||
|
"--status-label", "ready",
|
||||||
|
])
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT")
|
||||||
|
self.assertEqual(put_call[0][3], {"labels": [1, 2]})
|
||||||
|
|
||||||
|
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||||
|
def test_require_workflow_labels_blocks_missing_labels(self, _cred):
|
||||||
|
with patch("create_issue.api_request") as mock_api:
|
||||||
|
rc = create_issue.main(["--title", "My Title", "--require-workflow-labels"])
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||||
def test_url_construction(self, _cred):
|
def test_url_construction(self, _cred):
|
||||||
with patch("create_issue.api_request",
|
with patch("create_issue.api_request",
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Tests for canonical issue workflow label policy (#513)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import issue_workflow_labels as labels # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssueWorkflowLabelValidation(unittest.TestCase):
|
||||||
|
def test_valid_labeled_issue(self):
|
||||||
|
result = labels.assess_issue_labels(["type:feature", "status:ready"])
|
||||||
|
self.assertTrue(result["valid"])
|
||||||
|
self.assertEqual(result["type_labels"], ["type:feature"])
|
||||||
|
self.assertEqual(result["status_labels"], ["status:ready"])
|
||||||
|
|
||||||
|
def test_issue_missing_type_label(self):
|
||||||
|
result = labels.assess_issue_labels(["status:ready"])
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertIn("issue is missing a type:* label", result["errors"])
|
||||||
|
|
||||||
|
def test_issue_missing_status_label(self):
|
||||||
|
result = labels.assess_issue_labels(["type:bug"])
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertIn("issue is missing a status:* label", result["errors"])
|
||||||
|
|
||||||
|
def test_discussion_issue_missing_type_discussion(self):
|
||||||
|
result = labels.assess_issue_labels(
|
||||||
|
["type:feature", "status:triage"],
|
||||||
|
discussion=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertIn("discussion issue is missing type:discussion", result["errors"])
|
||||||
|
|
||||||
|
def test_multiple_conflicting_status_labels(self):
|
||||||
|
result = labels.assess_issue_labels(
|
||||||
|
["type:feature", "status:ready", "status:blocked"]
|
||||||
|
)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("multiple active status:* labels" in err for err in result["errors"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssueWorkflowStatusTransitions(unittest.TestCase):
|
||||||
|
def test_status_transition_removes_old_status_label(self):
|
||||||
|
result = labels.transition_status_labels(
|
||||||
|
["type:feature", "status:ready", "workflow"],
|
||||||
|
"in-progress",
|
||||||
|
)
|
||||||
|
self.assertEqual(result, ["type:feature", "workflow", "status:in-progress"])
|
||||||
|
|
||||||
|
def test_pr_open_transition_applies_status_pr_open(self):
|
||||||
|
result = labels.transition_status_labels(
|
||||||
|
["type:feature", "status:in-progress"],
|
||||||
|
"pr-open",
|
||||||
|
)
|
||||||
|
self.assertEqual(result, ["type:feature", "status:pr-open"])
|
||||||
|
|
||||||
|
def test_duplicate_transition_applies_status_duplicate(self):
|
||||||
|
result = labels.transition_status_labels(
|
||||||
|
["type:process", "status:triage"],
|
||||||
|
"duplicate",
|
||||||
|
)
|
||||||
|
self.assertEqual(result, ["type:process", "status:duplicate"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -164,13 +164,14 @@ class TestAllowedIssueWritesProceed(IssueWriteGateBase):
|
|||||||
result = mcp_server.gitea_close_issue(issue_number=9, remote="prgs")
|
result = mcp_server.gitea_close_issue(issue_number=9, remote="prgs")
|
||||||
self.assertTrue(result.get("success"))
|
self.assertTrue(result.get("success"))
|
||||||
|
|
||||||
|
@patch("mcp_server.api_get_all", return_value=[{"id": 10, "name": "status:in-progress"}])
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_resolver_allowed_mark_issue_proceeds(self, _auth, mock_api):
|
def test_resolver_allowed_mark_issue_proceeds(self, _auth, mock_api, _labels):
|
||||||
def api_side_effect(method, url, auth, payload=None):
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
if method == "GET" and url.endswith("/labels?limit=100"):
|
if method == "GET" and "/issues/9" in url:
|
||||||
return [{"id": 10, "name": "status:in-progress"}]
|
return {"labels": []}
|
||||||
if method == "POST" and "/issues/9/labels" in url:
|
if method == "PUT" and "/issues/9/labels" in url:
|
||||||
return [{"name": "status:in-progress"}]
|
return [{"name": "status:in-progress"}]
|
||||||
if method == "POST" and "/issues/9/comments" in url:
|
if method == "POST" and "/issues/9/comments" in url:
|
||||||
return {"id": 501}
|
return {"id": 501}
|
||||||
@@ -209,4 +210,4 @@ class TestCreateIssueMissingPermissionReport(IssueWriteGateBase):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from unittest.mock import MagicMock, call, patch
|
|||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
import manage_labels # noqa: E402
|
import manage_labels # noqa: E402
|
||||||
|
import issue_workflow_labels # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0" # base64("test:test")
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0" # base64("test:test")
|
||||||
@@ -129,6 +130,11 @@ class TestConstants(unittest.TestCase):
|
|||||||
names = [l["name"] for l in manage_labels.LABELS]
|
names = [l["name"] for l in manage_labels.LABELS]
|
||||||
self.assertIn("status:in-progress", names)
|
self.assertIn("status:in-progress", names)
|
||||||
|
|
||||||
|
def test_canonical_workflow_labels_are_defined(self):
|
||||||
|
names = {l["name"] for l in manage_labels.LABELS}
|
||||||
|
for spec in issue_workflow_labels.CANONICAL_LABEL_SPECS:
|
||||||
|
self.assertIn(spec.name, names)
|
||||||
|
|
||||||
def test_all_mapped_labels_are_defined(self):
|
def test_all_mapped_labels_are_defined(self):
|
||||||
defined = {l["name"] for l in manage_labels.LABELS}
|
defined = {l["name"] for l in manage_labels.LABELS}
|
||||||
for issue, names in manage_labels.MAPPING.items():
|
for issue, names in manage_labels.MAPPING.items():
|
||||||
|
|||||||
+121
-18
@@ -91,6 +91,16 @@ def _visible_approval_reviews(reviewer="reviewer-bot", sha="abc123"):
|
|||||||
|
|
||||||
_DEFAULT_LEASE_SESSION = "mcp-test-reviewer-lease"
|
_DEFAULT_LEASE_SESSION = "mcp-test-reviewer-lease"
|
||||||
_NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True}
|
_NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True}
|
||||||
|
WORKFLOW_REPO_LABELS = [
|
||||||
|
{"id": 1, "name": "type:feature"},
|
||||||
|
{"id": 2, "name": "status:in-progress"},
|
||||||
|
{"id": 3, "name": "status:ready"},
|
||||||
|
{"id": 4, "name": "status:pr-open"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_with_labels(*names):
|
||||||
|
return {"labels": [{"name": name} for name in names]}
|
||||||
|
|
||||||
|
|
||||||
def _reviewer_lease_comment(
|
def _reviewer_lease_comment(
|
||||||
@@ -305,6 +315,22 @@ class TestCreateIssue(unittest.TestCase):
|
|||||||
self.assertIn("gitea.prgs.cc", url)
|
self.assertIn("gitea.prgs.cc", url)
|
||||||
self.assertIn("Scaled-Tech-Consulting", url)
|
self.assertIn("Scaled-Tech-Consulting", url)
|
||||||
|
|
||||||
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
|
return_value=(True, []))
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.api_get_all", return_value=[])
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_create_issue_required_workflow_labels_blocks(self, _auth, _get_all, mock_api, _role):
|
||||||
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
|
result = gitea_create_issue(
|
||||||
|
title="Test issue",
|
||||||
|
require_workflow_labels=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertIn("issue is missing a type:* label", result["reasons"])
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Create PR
|
# Create PR
|
||||||
@@ -315,13 +341,24 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
"mcp_server.issue_duplicate_context_fetcher",
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
return_value=([], [], {"status": "not_claimed"}),
|
return_value=([], [], {"status": "not_claimed"}),
|
||||||
)
|
)
|
||||||
|
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
|
||||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
return_value=(True, []))
|
return_value=(True, []))
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_creates_pr(self, _auth, mock_api, _role, _dup_fetcher):
|
def test_creates_pr(self, _auth, mock_api, _role, _labels, _dup_fetcher):
|
||||||
worktree = os.path.realpath(os.getcwd())
|
worktree = os.path.realpath(os.getcwd())
|
||||||
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
|
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "GET" and "/issues/123" in url:
|
||||||
|
return _issue_with_labels("type:feature", "status:in-progress")
|
||||||
|
if method == "POST" and url.endswith("/pulls"):
|
||||||
|
return {"number": 3, "html_url": "https://example.com/pulls/3"}
|
||||||
|
if method == "PUT" and url.endswith("/issues/123/labels"):
|
||||||
|
return [{"name": "type:feature"}, {"name": "status:pr-open"}]
|
||||||
|
return {}
|
||||||
|
|
||||||
|
mock_api.side_effect = api_side_effect
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
@@ -334,22 +371,37 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(result["number"], 3)
|
self.assertEqual(result["number"], 3)
|
||||||
self.assertNotIn("url", result)
|
self.assertNotIn("url", result)
|
||||||
payload = mock_api.call_args[0][3]
|
post_call = next(c for c in mock_api.call_args_list if c[0][0] == "POST")
|
||||||
|
payload = post_call[0][3]
|
||||||
self.assertEqual(payload["head"], "feat/x")
|
self.assertEqual(payload["head"], "feat/x")
|
||||||
self.assertEqual(payload["base"], "main")
|
self.assertEqual(payload["base"], "main")
|
||||||
self.assertIn("Closes #123", payload["title"])
|
self.assertIn("Closes #123", payload["title"])
|
||||||
|
put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT")
|
||||||
|
self.assertEqual(put_call[0][3], {"labels": [1, 4]})
|
||||||
|
self.assertTrue(result["issue_status_transition"]["performed"])
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
"mcp_server.issue_duplicate_context_fetcher",
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
return_value=([], [], {"status": "not_claimed"}),
|
return_value=([], [], {"status": "not_claimed"}),
|
||||||
)
|
)
|
||||||
|
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
|
||||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
return_value=(True, []))
|
return_value=(True, []))
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api, _role, _dup_fetcher):
|
def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api, _role, _labels, _dup_fetcher):
|
||||||
worktree = os.path.realpath(os.getcwd())
|
worktree = os.path.realpath(os.getcwd())
|
||||||
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
|
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "GET" and "/issues/123" in url:
|
||||||
|
return _issue_with_labels("type:feature", "status:in-progress")
|
||||||
|
if method == "POST" and url.endswith("/pulls"):
|
||||||
|
return {"number": 3, "html_url": "https://example.com/pulls/3"}
|
||||||
|
if method == "PUT" and url.endswith("/issues/123/labels"):
|
||||||
|
return [{"name": "type:feature"}, {"name": "status:pr-open"}]
|
||||||
|
return {}
|
||||||
|
|
||||||
|
mock_api.side_effect = api_side_effect
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
@@ -362,6 +414,38 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("pulls/3", result["url"])
|
self.assertIn("pulls/3", result["url"])
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
|
return_value=([], [], {"status": "not_claimed"}),
|
||||||
|
)
|
||||||
|
@patch("mcp_server.api_get_all", return_value=[{"id": 1, "name": "type:feature"}])
|
||||||
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
|
return_value=(True, []))
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_create_pr_blocks_when_pr_open_status_label_missing(
|
||||||
|
self, _auth, mock_api, _role, _labels, _dup_fetcher
|
||||||
|
):
|
||||||
|
worktree = os.path.realpath(os.getcwd())
|
||||||
|
mock_api.return_value = _issue_with_labels("type:feature", "status:in-progress")
|
||||||
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
||||||
|
result = gitea_create_pr(
|
||||||
|
title="feat: X Closes #123",
|
||||||
|
head="feat/x",
|
||||||
|
base="main",
|
||||||
|
worktree_path=worktree,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertIn("status:pr-open", result["reasons"][0])
|
||||||
|
self.assertFalse(
|
||||||
|
any(c[0][0] == "POST" and c[0][1].endswith("/pulls")
|
||||||
|
for c in mock_api.call_args_list)
|
||||||
|
)
|
||||||
|
|
||||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
return_value=(True, []))
|
return_value=(True, []))
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@@ -486,26 +570,33 @@ class TestViewIssue(unittest.TestCase):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestMarkIssue(unittest.TestCase):
|
class TestMarkIssue(unittest.TestCase):
|
||||||
|
|
||||||
|
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_start_adds_label(self, _auth, mock_api):
|
def test_start_adds_label(self, _auth, mock_api, _labels):
|
||||||
# labels, add label, post claim heartbeat comment
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
mock_api.side_effect = [
|
if method == "GET" and "/issues/5" in url:
|
||||||
[{"id": 10, "name": "status:in-progress"}],
|
return _issue_with_labels("type:feature", "status:ready")
|
||||||
[{"name": "status:in-progress"}],
|
if method == "PUT" and url.endswith("/issues/5/labels"):
|
||||||
{"id": 99},
|
return [{"name": "type:feature"}, {"name": "status:in-progress"}]
|
||||||
]
|
if method == "POST" and url.endswith("/issues/5/comments"):
|
||||||
|
return {"id": 99}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
mock_api.side_effect = api_side_effect
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
result = gitea_mark_issue(issue_number=5, action="start")
|
result = gitea_mark_issue(issue_number=5, action="start")
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
self.assertIn("claimed", result["message"])
|
self.assertIn("claimed", result["message"])
|
||||||
self.assertTrue(result.get("heartbeat_posted"))
|
self.assertTrue(result.get("heartbeat_posted"))
|
||||||
|
put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT")
|
||||||
|
self.assertEqual(put_call[0][3], {"labels": [1, 2]})
|
||||||
|
|
||||||
|
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_done_removes_label(self, _auth, mock_api):
|
def test_done_removes_label(self, _auth, mock_api, _labels):
|
||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
[{"id": 10, "name": "status:in-progress"}],
|
|
||||||
None,
|
None,
|
||||||
]
|
]
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
@@ -517,10 +608,10 @@ class TestMarkIssue(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
gitea_mark_issue(issue_number=5, action="pause")
|
gitea_mark_issue(issue_number=5, action="pause")
|
||||||
|
|
||||||
|
@patch("mcp_server.api_get_all", return_value=[])
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_missing_label_raises(self, _auth, mock_api):
|
def test_missing_label_raises(self, _auth, mock_api, _labels):
|
||||||
mock_api.return_value = [] # no labels exist
|
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
gitea_mark_issue(issue_number=5, action="start")
|
gitea_mark_issue(issue_number=5, action="start")
|
||||||
@@ -3765,13 +3856,24 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("lock provenance", str(ctx.exception).lower())
|
self.assertIn("lock provenance", str(ctx.exception).lower())
|
||||||
|
|
||||||
|
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
return_value=(True, []))
|
return_value=(True, []))
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api):
|
def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api, _labels):
|
||||||
scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-e2e")
|
scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-e2e")
|
||||||
mock_api.return_value = {"number": 250, "html_url": "https://example/pr/250"}
|
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "GET" and "/issues/249" in url:
|
||||||
|
return _issue_with_labels("type:feature", "status:in-progress")
|
||||||
|
if method == "POST" and url.endswith("/pulls"):
|
||||||
|
return {"number": 250, "html_url": "https://example/pr/250"}
|
||||||
|
if method == "PUT" and url.endswith("/issues/249/labels"):
|
||||||
|
return [{"name": "type:feature"}, {"name": "status:pr-open"}]
|
||||||
|
return {}
|
||||||
|
|
||||||
|
mock_api.side_effect = api_side_effect
|
||||||
_bind_test_lock(
|
_bind_test_lock(
|
||||||
issue_number=249,
|
issue_number=249,
|
||||||
branch_name="feat/issue-249-issue-lock-scratch-worktree",
|
branch_name="feat/issue-249-issue-lock-scratch-worktree",
|
||||||
@@ -3786,6 +3888,7 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
worktree_path=scratch,
|
worktree_path=scratch,
|
||||||
)
|
)
|
||||||
self.assertEqual(res["number"], 250)
|
self.assertEqual(res["number"], 250)
|
||||||
|
self.assertTrue(res["issue_status_transition"]["performed"])
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user