Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions bin/gh-wrapper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,46 @@ _identity_footer() {
echo -e "$parts"
}

# Structured blocker marker for needs-human reports (hivecommons/hive#15099).
#
# A report that says the App / token / hive *cannot* do something (push to
# .github/workflows without the `workflows` permission, change branch
# protection, set a secret, sign a CLA) describes a capability gap no
# "Let hive decide" can clear. pkg/blocker reads `<!-- hive:blocker kind=… -->`
# and parks the item on that blocker instead of a bare needs-human, so the
# dashboard says what is missing and the delegated decision answers
# "no-effect" with the real options. Prose is classified here with the same
# narrow rules as pkg/blocker.Infer (mirrored, not shared: this file runs
# where no Go binary is guaranteed). Only bodies that cite needs-human are
# marked, a body that already carries a marker is left alone, and anything
# the rules do not match stays unmarked β€” a wrong kind is worse than none.
_blocker_kind() {
local body
body="$(printf '%s' "$1" | tr '\n' ' ' | tr '[:upper:]' '[:lower:]')"
[[ "$body" == *"hive:blocker"* ]] && return 1
[[ "$body" =~ needs[-\ ]human|needs\ a\ (human|person|maintainer)|a\ maintainer\ must|a\ person\ must ]] || return 1
local cannot="(cannot|can\'t|can not|unable to|not able to|no permission to|not permitted to|is not allowed to)"
if [[ "$body" =~ (no|lacks?|lacking|missing|without)\ (the\ )?\`?workflows?\`?(:\ ?write)?\ permission ]] \
|| [[ "$body" =~ $cannot\ (push|modify|change|edit|write|update|touch|commit)[^.]{0,80}(\.github/workflows|workflow\ files?|workflows?/) ]] \
|| [[ "$body" =~ workflows?\ permission[^.]{0,40}(missing|lacking|not\ granted|needed|required) ]]; then
echo "capability:workflows-permission"; return 0
fi
if [[ "$body" =~ $cannot[^.]{0,60}(branch[\ -]protection|rulesets?|protected\ branch) ]]; then
echo "capability:branch-protection"; return 0
fi
if [[ "$body" =~ $cannot\ (set|add|create|read|rotate|update|configure)[^.]{0,60}(secrets?|credentials?) ]]; then
echo "capability:secret"; return 0
fi
if [[ "$body" =~ $cannot\ (push|write|commit)[^.]{0,60}(repository|repo|branch|fork) ]] \
|| [[ "$body" =~ (no|lacks?|missing|without)\ (the\ )?\`?(contents|write|push)\`?(:\ ?write)?\ (permission|access) ]]; then
echo "capability:repo-write"; return 0
fi
if [[ "$body" =~ (sign|signing|signed)[^.]{0,30}(cla|contributor\ license\ agreement) ]]; then
echo "human-only:cla"; return 0
fi
return 1
}

# Guard Markdown thematic breaks against becoming setext heading underlines.
#
# A line of `---` placed DIRECTLY under a paragraph is not a rule in Markdown β€”
Expand Down Expand Up @@ -1319,6 +1359,11 @@ _inject_identity() {
# the gap is exactly one blank line regardless of how the file ended.
body_val="$(printf '%s\n' "${body_val}" | _guard_markdown_rules)"
body_val="${body_val%"${body_val##*[![:space:]]}"}"
local blocker_kind
if blocker_kind="$(_blocker_kind "$body_val")"; then
body_val="${body_val}
<!-- hive:blocker kind=${blocker_kind} -->"
fi
new_args+=("--body" "${body_val}

${footer}")
Expand Down
39 changes: 39 additions & 0 deletions bin/gh-wrapper.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,45 @@ else
_body_fail "in-body --- rules" "no recorded 'gh issue comment' invocation"
fi

# A needs-human report naming a capability the App lacks gets the structured
# blocker marker pkg/blocker reads (hivecommons/hive#15099): the dashboard
# then parks the item on "App lacks workflows permission" instead of a bare
# needs-human that "Let hive decide" un-parks and the scanner re-parks.
BLOCKER_BODY_FILE="${WORK_DIR}/blocker-body.md"
printf '%s\n' 'Verified: the Dockerfile wrappers are gone.' '' 'The remaining retry wrappers live in `.github/workflows/v2-ci.yml`, which the App token cannot push (no `workflows` permission); needs a human.' >"$BLOCKER_BODY_FILE"
_capture_run /dev/null issue comment 42 --repo test/repo -F "$BLOCKER_BODY_FILE"
if inv="$(_capture_invocation issue comment)" && body="$(_capture_body "$inv")"; then
desc="issue comment -F <needs-human: App lacks workflows permission>: gets <!-- hive:blocker kind=capability:workflows-permission --> before the footer"
if [[ "$body" == *$'needs a human.\n<!-- hive:blocker kind=capability:workflows-permission -->\n\n---\n'* ]]; then
echo "PASS: $desc"; PASSED=$((PASSED + 1))
else
_body_fail "$desc" "marker missing or misplaced" "body: ${body}"
fi
else
_body_fail "blocker marker" "no recorded 'gh issue comment' invocation"
fi

# A body that already carries a marker, and a needs-human that names no
# capability, are left exactly as written: a wrong kind is worse than none.
_capture_run /dev/null issue comment 42 --repo test/repo --body 'The App cannot push workflow files; needs a human. <!-- hive:blocker kind=capability:repo-write -->'
if inv="$(_capture_invocation issue comment)" && body="$(_capture_body "$inv")"; then
desc="issue comment --body <already marked>: no second blocker marker"
if [[ "$(printf '%s' "$body" | grep -c 'hive:blocker')" == "1" ]]; then
echo "PASS: $desc"; PASSED=$((PASSED + 1))
else
_body_fail "$desc" "expected exactly one marker" "body: ${body}"
fi
fi
_capture_run /dev/null issue comment 42 --repo test/repo --body 'needs-human: the flaky test touches workflows and a maintainer should look.'
if inv="$(_capture_invocation issue comment)" && body="$(_capture_body "$inv")"; then
desc="issue comment --body <needs-human without a capability gap>: no blocker marker"
if [[ "$body" != *'hive:blocker'* ]]; then
echo "PASS: $desc"; PASSED=$((PASSED + 1))
else
_body_fail "$desc" "unexpected marker" "body: ${body}"
fi
fi

# No body at all is still the case that MUST add one.
_capture_run /dev/null pr comment 42 --repo test/repo
_expect_injected_body "pr comment with no body flag still gets --body <footer>" \
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fixed-capability-blocker-decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- "Let hive decide" on an issue parked because the GitHub App lacks a permission (the scanner's "cannot push `.github/workflows/*` (no `workflows` permission); needs a human") no longer un-parks it only for the next sweep to re-park it: the report is recorded as a structured blocker (`<!-- hive:blocker kind=capability:workflows-permission -->`, inferred from the prose when the marker is missing), the delegated decision returns no-effect with the plain-words reason and the ways out (grant the permission with a link to the App settings, re-check permissions, mark partially addressed and close, keep waiting), a loop guard disables the delegate once the same reason comes back after a delegated un-park, `stage/045-needs-decision` and `approved-direction` no longer coexist, the tracker row reads "Blocked Β· App lacks workflows permission", and βš™ β†’ Security plus `/api/status.capabilities` name the missing permissions and how many items wait on them ([#15099](https://github.com/hivecommons/hive/issues/15099)).
241 changes: 241 additions & 0 deletions dashboard/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -33873,6 +33873,247 @@
}
}
}
},
"/api/repos/{owner}/{repo}/items/{number}/blocker/recheck": {
"post": {
"tags": [
"Status"
],
"summary": "Re-check the GitHub App's permissions for an issue parked on a capability blocker",
"description": "Backs the *Re-check permissions* option on an issue the tracker shows as *Blocked Β· App lacks <permission> permission* (hivecommons/hive#15099). Probes the App installation's permissions now: when the missing permission (for example `workflows: write`) is granted, every ledger entry blocked on it is cleared, the issue is un-parked and the result reads `released`; otherwise the result is `no-effect` with the plain-words reason and the same options the popover lists. 404 when the item has no recorded blocker, 409 when its blocker is not a capability gap (a human-only step no permission grant clears), 502 when the installation lookup fails. Same authorisation as the hold toggle plus a signed-in GitHub user; audited as repo_item_blocker_recheck.",
"parameters": [
{
"name": "owner",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "repo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "number",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Probe completed",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean"
},
"repo": {
"type": "string"
},
"number": {
"type": "integer"
},
"permission": {
"type": "string",
"description": "The permission the blocker names"
},
"granted": {
"type": "boolean",
"description": "True when the installation now has it"
},
"released": {
"type": "integer",
"description": "Items un-blocked by this grant"
},
"capabilities": {
"type": "object",
"description": "Fleet-wide missing permissions after the probe (same shape as /api/status.capabilities)"
},
"result": {
"type": "object",
"description": "RowActionResult the activity monitor's result toast renders",
"properties": {
"action": {
"type": "string"
},
"target": {
"type": "object"
},
"outcome": {
"type": "string"
},
"next": {
"type": "string"
},
"reason": {
"type": "string"
},
"options": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "Dashboard session or GitHub user sign-in required"
},
"403": {
"description": "Not permitted for this repository"
},
"404": {
"description": "No blocker is recorded on this item"
},
"409": {
"description": "The blocker is not a capability gap"
},
"502": {
"description": "Installation permission probe failed"
}
}
}
},
"/api/repos/{owner}/{repo}/items/{number}/blocker/close-partial": {
"post": {
"tags": [
"Status"
],
"summary": "Mark an issue parked on a blocker partially addressed and close it",
"description": "Backs the *Mark partially addressed and close* option on a blocked issue (hivecommons/hive#15099): adds `hive/partially-addressed`, posts the operator's comment naming what remains (the blocker in plain words when the comment is empty), closes the issue as completed and clears the blocker ledger entry. 404 when the item has no recorded blocker; 409 when the issue is on hold. Same authorisation as the hold toggle plus a signed-in GitHub user; audited as repo_item_blocker_close_partial.",
"parameters": [
{
"name": "owner",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "repo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "number",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"comment": {
"type": "string",
"description": "What remains for a person, posted on the issue"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Issue closed",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean"
},
"repo": {
"type": "string"
},
"number": {
"type": "integer"
},
"decision": {
"type": "string"
},
"status": {
"type": "string"
},
"label": {
"type": "string"
},
"result": {
"type": "object",
"description": "RowActionResult the activity monitor's result toast renders",
"properties": {
"action": {
"type": "string"
},
"target": {
"type": "object"
},
"outcome": {
"type": "string"
},
"next": {
"type": "string"
},
"reason": {
"type": "string"
},
"options": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "Dashboard session or GitHub user sign-in required"
},
"403": {
"description": "Not permitted for this repository"
},
"404": {
"description": "No blocker is recorded on this item"
},
"409": {
"description": "The issue is on hold"
},
"502": {
"description": "GitHub write failed"
}
}
}
}
},
"components": {
Expand Down
Loading
Loading