feat: add label update support to update-discussion safe-output#22384
feat: add label update support to update-discussion safe-output#22384
Conversation
Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/16915229-625b-491a-ae2b-fbc8e13b3d49
|
@copilot List the form of expected ops calls for update project, given one wants to either add, remove labels or update a set to a give list. |
For Always runs first: For any label update, label IDs are resolved next: Then, based on the diff:
So the pattern is always: fetch current state → diff → apply only the delta. The agent specifies the desired final set in If |
|
@copilot Summarize the fix. |
The
|
|
@copilot This should be consistent with how issue labels are handled in |
Remove the allow_labels === true gate from buildDiscussionUpdateData so labels are always processed when provided, consistent with update_issue behavior. Optional allowed_labels config still restricts which labels may be set. Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/1ed3573c-43c2-487f-b854-fbc8e7b139f8
Fixed in 53f187d. The |
|
@copilot review update consistency with |
…Data - Remove unused parseBoolTemplatable import and _includeFooter assignment from buildDiscussionUpdateData; executeDiscussionUpdate never reads it - Export buildDiscussionUpdateData matching update_issue's export of buildIssueUpdateData for unit-test parity Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/bb00d1b8-9f7d-4585-9d0d-eb12204d2716
Remaining inconsistencies fixed in 66d3e52:
The structural API differences (GraphQL vs REST, no append/prepend operations, no footer attribution for discussions) are intentional given the different GitHub APIs involved. |
There was a problem hiding this comment.
Pull request overview
Adds labels support to the update_discussion safe-output so discussions can be updated via title/body and/or label replacement, including config/schema/validation and new Vitest coverage.
Changes:
- Extend safe-output validation and MCP tool schema to accept
labelsforupdate_discussion. - Implement discussion label replacement via GraphQL label ID resolution + add/remove mutations.
- Add a new
update_discussionVitest suite covering label add/remove/replace/no-op and mixed updates.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| pkg/workflow/safe_outputs_validation_config.go | Allows labels as a valid requiresOneOf field and validates label arrays. |
| actions/setup/js/safe_outputs_tools.json | Exposes labels in the update_discussion tool input schema and updates description. |
| actions/setup/js/update_discussion.cjs | Implements label resolution and add/remove mutations, plus label processing in update data builder. |
| actions/setup/js/update_discussion.test.cjs | Adds tests covering the new label update behavior and combinations with title updates. |
Comments suppressed due to low confidence (1)
actions/setup/js/update_discussion.cjs:313
validateLabels(...)can returnvalid:falsefor reasons other than “all labels were filtered out” (e.g., label removal attempts like-bug, non-array input, or sanitization producing an empty set). Treating any!validresult asupdateData.labels = []makes a validation failure/destructive input translate into “clear all labels”, which can bypassallowed_labelsintent. Instead, handle errors explicitly (return{success:false, error: ...}for true validation errors) and only treat the specific “no valid labels after allowed filtering” case as a no-op (leavelabelsundefined so existing labels aren’t removed).
// Filter to allowed labels only; if none remain treat as an empty set
const labelsResult = validateLabels(item.labels, allowedLabels);
if (!labelsResult.valid) {
// All labels were filtered out (e.g. none in allowed list) - treat as empty set
updateData.labels = [];
} else {
updateData.labels = labelsResult.value ?? [];
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (allowedLabels.length > 0) { | ||
| // Filter to allowed labels only; if none remain treat as an empty set | ||
| const labelsResult = validateLabels(item.labels, allowedLabels); | ||
| if (!labelsResult.valid) { | ||
| // All labels were filtered out (e.g. none in allowed list) - treat as empty set | ||
| updateData.labels = []; | ||
| } else { | ||
| updateData.labels = labelsResult.value ?? []; | ||
| } |
There was a problem hiding this comment.
In the allowed-labels branch, validateLabels(item.labels, allowedLabels) uses validateLabels' default maxCount=3, which will silently truncate discussions to 3 labels even though MAX_LABELS is 10 and you already enforce tryEnforceArrayLimit with MAX_LABELS. Pass MAX_LABELS (and any other intended params) into validateLabels, or avoid calling validateLabels for limiting and do only allowed-list filtering + sanitization here.
This issue also appears on line 306 of the same file.
| // Compute add/remove sets | ||
| const labelsToAdd = requestedLabelData.filter(l => !currentLabelIds.has(l.id)).map(/** @param {any} l */ l => l.id); | ||
| const labelsToRemove = currentLabels.filter(/** @param {any} l */ l => !requestedLabelIdSet.has(l.id)).map(/** @param {any} l */ l => l.id); | ||
|
|
There was a problem hiding this comment.
Label replacement currently removes any existing labels whose IDs aren’t in requestedLabelIdSet, but requestedLabelIdSet is derived only from labels successfully resolved by fetchLabelIds. If the agent provides a label that doesn’t exist (or isn’t in the first 100 repo labels), fetchLabelIds will drop it and you can end up removing all existing labels unintentionally. Consider failing the update when any requested labels can’t be resolved, or (safer) only perform removals when all requested labels were successfully resolved.
| // Compute add/remove sets | |
| const labelsToAdd = requestedLabelData.filter(l => !currentLabelIds.has(l.id)).map(/** @param {any} l */ l => l.id); | |
| const labelsToRemove = currentLabels.filter(/** @param {any} l */ l => !requestedLabelIdSet.has(l.id)).map(/** @param {any} l */ l => l.id); | |
| // Detect any requested labels that could not be resolved | |
| const resolvedLabelNames = new Set(requestedLabelData.map(/** @param {any} l */ l => l.name)); | |
| const unresolvedLabels = Array.isArray(updateData.labels) | |
| ? updateData.labels.filter((/** @type {string} */ name) => !resolvedLabelNames.has(name)) | |
| : []; | |
| // Compute add/remove sets | |
| const labelsToAdd = requestedLabelData | |
| .filter((/** @param {any} l */ l) => !currentLabelIds.has(l.id)) | |
| .map((/** @param {any} l */ l) => l.id); | |
| /** @type {string[]} */ | |
| let labelsToRemove = []; | |
| if (unresolvedLabels.length === 0) { | |
| // Only remove labels when all requested labels were successfully resolved | |
| labelsToRemove = currentLabels | |
| .filter((/** @param {any} l */ l) => !requestedLabelIdSet.has(l.id)) | |
| .map((/** @param {any} l */ l) => l.id); | |
| } else { | |
| core.warning( | |
| `Skipping label removals for discussion #${discussionNumber} because some requested labels could not be resolved: ${unresolvedLabels.join( | |
| ", " | |
| )}` | |
| ); | |
| } |
|
update-discussionsupported title and body updates but not labels, despite the config already parsinglabels/allowed-labelsfields and generatingallow_labels/allowed_labelsruntime config.Changes
Validation (
safe_outputs_validation_config.go)requiresOneOffromtitle,body→title,body,labelsso a labels-only update is validlabelsfield validation (array of strings, sanitized, max 128 chars each)MCP tool schema (
safe_outputs_tools.json)labelsarray field toupdate_discussioninput schemaRuntime handler (
update_discussion.cjs)fetchLabelIds()— resolves label names → GitHub node IDs (case-insensitive, up to 100 repo labels)addLabelsToDiscussion()/removeLabelsFromDiscussion()— GraphQLaddLabelsToLabelable/removeLabelsFromLabelablemutationsexecuteDiscussionUpdate()— fetches current labels (up to 100) in the initial query, then performs a replacement diff (add new, remove old) after any title/body updatebuildDiscussionUpdateData()— extracts and processes labels whenever provided (consistent withupdate_issue); filters againstallowed_labelswhen configuredExample config
When
target: "*", the agent must includediscussion_numberin its output. A label update withouttitle:orbody:enabled does not call theupdateDiscussionmutation — only the label mutations run.Tests (
update_discussion.test.cjs)New test file covering: add, remove, full replacement, no-op (unchanged),
allowed-labelsfiltering, always-on label processing (consistent withupdate_issue), combined title+labels, and labels-only update.💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.