From 9727ed454742f2f62522d3cf11ea60083dd0f9ad Mon Sep 17 00:00:00 2001 From: Mason Huang Date: Wed, 15 Apr 2026 08:11:03 +0800 Subject: [PATCH] feat(skills): add discussion_comment support to secret-scanning skill (#65628) Merged via squash. Prepared head SHA: 071e9f4b7ae93b3ee30f0f68faa7876a32bd29f4 Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819 --- .../SKILL.md | 41 ++- .../scripts/secret-scanning.mjs | 282 +++++++++++++++++- 2 files changed, 297 insertions(+), 26 deletions(-) diff --git a/.agents/skills/openclaw-secret-scanning-maintainer/SKILL.md b/.agents/skills/openclaw-secret-scanning-maintainer/SKILL.md index 6b84451a0ee..1d21fb5d18b 100644 --- a/.agents/skills/openclaw-secret-scanning-maintainer/SKILL.md +++ b/.agents/skills/openclaw-secret-scanning-maintainer/SKILL.md @@ -61,18 +61,20 @@ The `fetch-content` output includes: - `issue_number` / `pr_number`: where it is - `edit_history_count`: number of existing edits - `type`: location type for routing +- For `discussion_comment`, it also includes `comment_node_id`, `discussion_node_id`, and `reply_to_node_id` when the original comment was a reply. ### Location type routing -| type | Flow | -| ----------------------------- | ------------------------ | -| `issue_comment` | Comment: delete+recreate | -| `pull_request_comment` | Comment: delete+recreate | -| `pull_request_review_comment` | Comment: delete+recreate | -| `issue_body` | Body: redact in place | -| `pull_request_body` | Body: redact in place | -| `commit` | Notify only | -| _other_ | Skip and report | +| type | Flow | +| ----------------------------- | --------------------------------------------- | +| `issue_comment` | Comment: delete+recreate | +| `pull_request_comment` | Comment: delete+recreate | +| `pull_request_review_comment` | Comment: delete+recreate | +| `discussion_comment` | Discussion comment: delete+recreate (GraphQL) | +| `issue_body` | Body: redact in place | +| `pull_request_body` | Body: redact in place | +| `commit` | Notify only | +| _other_ | Skip and report | ## Step 2: Decide (Agent) @@ -100,15 +102,28 @@ node secret-scanning.mjs redact-body ### Comments — Delete and Recreate +For issue/PR comments: + ```bash # Delete original (all edit history gone) node secret-scanning.mjs delete-comment # Recreate with redacted content -# Agent prepares the body file with maintainer header + redacted content node secret-scanning.mjs recreate-comment ``` +For discussion comments (uses GraphQL): + +```bash +# Delete original +node secret-scanning.mjs delete-discussion-comment + +# Recreate with redacted content +node secret-scanning.mjs recreate-discussion-comment [REPLY_TO_NODE_ID] +``` + +The `fetch-content` output for `discussion_comment` includes `comment_node_id` and `discussion_node_id` for these commands. When the original discussion comment was a reply, it also includes `reply_to_node_id`; pass that optional third argument so the redacted replacement stays in the original thread. + The recreated comment should follow this format: ``` @@ -140,9 +155,13 @@ Cannot clean. Notify author to delete branch or force-push (for unmerged PRs). ## Step 5: Notify ```bash -node secret-scanning.mjs notify +node secret-scanning.mjs notify [REPLY_TO_NODE_ID] ``` +- For non-discussion types, `` is the issue/PR number. +- For `discussion_comment`, `` is the `discussion_node_id` returned by `fetch-content`. +- For reply-style `discussion_comment` locations, pass the optional `reply_to_node_id` from `fetch-content` so the notification stays in the same thread. + Secret types are comma-separated: `"Discord Bot Token,Feishu App Secret"` The script picks the right template: diff --git a/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs b/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs index d82ed948b03..3b58f66c56a 100644 --- a/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs +++ b/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs @@ -30,6 +30,14 @@ function gh(args, { json = true, allowFailure = false } = {}) { if (proc.status !== 0 && !allowFailure) { fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`); } + if (proc.status !== 0) { + return { + gh_failed: true, + status: proc.status, + stdout: proc.stdout, + stderr: proc.stderr, + }; + } if (!json) return proc.stdout; try { return JSON.parse(proc.stdout); @@ -38,8 +46,159 @@ function gh(args, { json = true, allowFailure = false } = {}) { } } -function ghGraphQL(query) { - return gh(["api", "graphql", "-f", `query=${query}`]); +function ghGraphQL(query, options = {}) { + return gh(["api", "graphql", "-f", `query=${query}`], options); +} + +function failOnGraphQLFailure(result, message) { + if (result?.gh_failed) { + const details = (result.stderr || result.stdout || `gh exited with status ${result.status}`).trim(); + fail(`${message}: ${details}`); + } + if (Array.isArray(result?.errors) && result.errors.length > 0) { + fail(`${message}: ${JSON.stringify(result.errors)}`); + } +} + +function escapeGraphQLString(value) { + return String(value) + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\r/g, "\\r") + .replace(/\n/g, "\\n"); +} + +function formatGraphQLAfterClause(cursor) { + return cursor ? `, after: "${escapeGraphQLString(cursor)}"` : ""; +} + +function findDiscussionCommentNode(nodes, discussionCommentDbId) { + return ( + nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null + ); +} + +function fetchDiscussionReplyPage(commentNodeId, cursor) { + const afterClause = formatGraphQLAfterClause(cursor); + return ghGraphQL(`{ + node(id: "${escapeGraphQLString(commentNodeId)}") { + ... on DiscussionComment { + replies(first: 100${afterClause}) { + pageInfo { hasNextPage endCursor } + nodes { + id + databaseId + author { login } + body + url + replyTo { id } + userContentEdits(first: 50) { + totalCount + } + } + } + } + } + }}`); +} + +function fetchDiscussionComment(discussionNumber, discussionCommentDbId) { + const [owner, name] = REPO.split("/"); + let discussionId = null; + let cursor = null; + let hasNextPage = true; + + while (hasNextPage) { + const afterClause = formatGraphQLAfterClause(cursor); + const gql = ghGraphQL( + `{ + repository(owner: "${owner}", name: "${name}") { + discussion(number: ${discussionNumber}) { + id + comments(first: 50${afterClause}) { + pageInfo { hasNextPage endCursor } + nodes { + id + databaseId + author { login } + body + url + replyTo { id } + userContentEdits(first: 50) { + totalCount + } + replies(first: 100) { + pageInfo { hasNextPage endCursor } + nodes { + id + databaseId + author { login } + body + url + replyTo { id } + userContentEdits(first: 50) { + totalCount + } + } + } + } + } + } + } + }`, + { allowFailure: true }, + ); + failOnGraphQLFailure(gql, `Failed to fetch discussion #${discussionNumber}`); + + const discussion = gql?.data?.repository?.discussion; + if (!discussion) + fail( + `Discussion #${discussionNumber} not found — it may have been deleted. The alert cannot be processed via this skill.`, + ); + + discussionId = discussion.id; + + for (const topLevelComment of discussion.comments.nodes) { + if (String(topLevelComment.databaseId) === String(discussionCommentDbId)) { + return { discussionId, comment: topLevelComment }; + } + + let reply = findDiscussionCommentNode(topLevelComment.replies.nodes, discussionCommentDbId); + let replyCursor = topLevelComment.replies.pageInfo.endCursor; + let hasMoreReplies = topLevelComment.replies.pageInfo.hasNextPage; + + while (!reply && hasMoreReplies) { + const replyPage = fetchDiscussionReplyPage(topLevelComment.id, replyCursor); + failOnGraphQLFailure(replyPage, `Failed to fetch replies for discussion comment ${topLevelComment.id}`); + const replies = replyPage?.data?.node?.replies; + if (!replies) fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`); + + reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId); + hasMoreReplies = replies.pageInfo.hasNextPage; + replyCursor = replies.pageInfo.endCursor; + } + + if (reply) return { discussionId, comment: reply }; + } + + hasNextPage = discussion.comments.pageInfo.hasNextPage; + cursor = discussion.comments.pageInfo.endCursor; + } + + return { discussionId, comment: null }; +} + +function createDiscussionComment(discussionNodeId, body, replyToNodeId) { + const replyToClause = replyToNodeId + ? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"` + : ""; + const result = ghGraphQL( + `mutation { addDiscussionComment(input: { discussionId: "${escapeGraphQLString(discussionNodeId)}"${replyToClause}, body: "${escapeGraphQLString(body)}" }) { comment { id url } } }`, + ); + if (result?.errors) { + fail(`Failed to create discussion comment: ${JSON.stringify(result.errors)}`); + } + return result?.data?.addDiscussionComment?.comment; } // ─── Commands ─────────────────────────────────────────────────────────────── @@ -93,12 +252,48 @@ function cmdFetchContent(locationJson) { const type = location.type; const details = location.details; - if ( + if (type === "discussion_comment") { + const commentUrl = details.discussion_comment_url; + if (!commentUrl) fail("No discussion_comment_url in location details"); + + const urlMatch = commentUrl.match(/discussions\/(\d+)#discussioncomment-(\d+)/); + if (!urlMatch) fail(`Cannot parse discussion comment URL: ${commentUrl}`); + const discussionNumber = urlMatch[1]; + const discussionCommentDbId = urlMatch[2]; + + const { discussionId, comment } = fetchDiscussionComment(discussionNumber, discussionCommentDbId); + if (!comment) + fail( + `Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`, + ); + + const bodyFile = tmpFile("body.md"); + fs.writeFileSync(bodyFile, comment.body || ""); + + console.log( + JSON.stringify( + { + type, + comment_node_id: comment.id, + discussion_node_id: discussionId, + reply_to_node_id: comment.replyTo?.id ?? null, + discussion_number: Number(discussionNumber), + discussion_comment_db_id: Number(discussionCommentDbId), + author: comment.author?.login, + html_url: comment.url || commentUrl, + edit_history_count: comment.userContentEdits?.totalCount ?? 0, + body_file: bodyFile, + }, + null, + 2, + ), + ); + } else if ( type === "issue_comment" || type === "pull_request_comment" || type === "pull_request_review_comment" ) { - // 从 url 中提取 comment ID + // Extract comment ID from URL const commentUrl = details.issue_comment_url || details.pull_request_comment_url || @@ -109,7 +304,7 @@ function cmdFetchContent(locationJson) { const bodyFile = tmpFile("body.md"); fs.writeFileSync(bodyFile, comment.body || ""); - // 获取编辑历史 + // Fetch edit history const nodeId = comment.node_id; const typeName = type === "pull_request_review_comment" ? "PullRequestReviewComment" : "IssueComment"; @@ -124,7 +319,7 @@ function cmdFetchContent(locationJson) { }`); const editCount = gql?.data?.node?.userContentEdits?.totalCount ?? 0; - // 提取 issue number(从 html_url) + // Extract issue number from html_url const htmlUrl = comment.html_url || details.html_url || ""; const issueMatch = htmlUrl.match(/\/(issues|pull)\/(\d+)/); const issueNumber = issueMatch ? issueMatch[2] : null; @@ -229,7 +424,7 @@ function cmdFetchContent(locationJson) { start_line: details.start_line, end_line: details.end_line, html_url: details.html_url || details.commit_url || details.blob_url || null, - // commit 没有 body 文件 + // No body file for commits body_file: null, }, null, @@ -278,6 +473,41 @@ function cmdDeleteComment(commentId) { console.log(JSON.stringify({ ok: true, deleted_comment_id: Number(commentId) })); } +/** + * delete-discussion-comment + * Delete a discussion comment via GraphQL (and all its edit history). + */ +function cmdDeleteDiscussionComment(nodeId) { + if (!nodeId) fail("Usage: delete-discussion-comment "); + const result = ghGraphQL( + `mutation { deleteDiscussionComment(input: { id: "${nodeId}" }) { comment { id } } }`, + ); + if (result?.errors) { + fail(`Failed to delete discussion comment: ${JSON.stringify(result.errors)}`); + } + console.log(JSON.stringify({ ok: true, deleted_node_id: nodeId })); +} + +/** + * recreate-discussion-comment [reply-to-node-id] + * Create a new discussion comment via GraphQL. + */ +function cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId) { + if (!discussionNodeId || !bodyFile) + fail("Usage: recreate-discussion-comment [reply-to-node-id]"); + if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`); + + const body = fs.readFileSync(bodyFile, "utf8"); + const newComment = createDiscussionComment(discussionNodeId, body, replyToNodeId); + console.log( + JSON.stringify({ + ok: true, + node_id: newComment?.id, + html_url: newComment?.url, + }), + ); +} + /** * recreate-comment * Create a new comment from a file. @@ -305,12 +535,15 @@ function cmdRecreateComment(issueNumber, bodyFile) { } /** - * notify + * notify [reply-to-node-id] * Post a notification comment with the correct template for the location type. + * target = issue/PR number for non-discussion types, discussion node ID for discussion_comment. */ -function cmdNotify(issueNumber, author, locationType, secretTypes) { - if (!issueNumber || !author || !locationType || !secretTypes) { - fail("Usage: notify "); +function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) { + if (!target || !author || !locationType || !secretTypes) { + fail( + "Usage: notify [reply-to-node-id]", + ); } const types = secretTypes.split(",").map((s) => s.trim()); @@ -321,7 +554,8 @@ function cmdNotify(issueNumber, author, locationType, secretTypes) { if ( locationType === "issue_comment" || locationType === "pull_request_comment" || - locationType === "pull_request_review_comment" + locationType === "pull_request_review_comment" || + locationType === "discussion_comment" ) { locationDesc = "your comment"; actionDesc = "The affected comment has been removed and replaced with a redacted version."; @@ -355,12 +589,26 @@ function cmdNotify(issueNumber, author, locationType, secretTypes) { .filter((line) => line !== undefined) .join("\n"); + // Discussion comments must be notified via GraphQL + if (locationType === "discussion_comment") { + const newComment = createDiscussionComment(target, body, replyToNodeId); + console.log( + JSON.stringify({ + ok: true, + node_id: newComment?.id, + html_url: newComment?.url, + }), + ); + return; + } + + // Issue/PR comments via REST const bodyFile = tmpFile("notify.md"); fs.writeFileSync(bodyFile, body); const result = gh([ "api", - `repos/${REPO}/issues/${issueNumber}/comments`, + `repos/${REPO}/issues/${target}/comments`, "-X", "POST", "-F", @@ -508,8 +756,10 @@ const commands = { "fetch-content": () => cmdFetchContent(args[0]), "redact-body": () => cmdRedactBody(args[0], args[1], args[2]), "delete-comment": () => cmdDeleteComment(args[0]), + "delete-discussion-comment": () => cmdDeleteDiscussionComment(args[0]), "recreate-comment": () => cmdRecreateComment(args[0], args[1]), - notify: () => cmdNotify(args[0], args[1], args[2], args[3]), + "recreate-discussion-comment": () => cmdRecreateDiscussionComment(args[0], args[1], args[2]), + notify: () => cmdNotify(args[0], args[1], args[2], args[3], args[4]), resolve: () => cmdResolve(args[0], args[1], args[2]), "list-open": () => cmdListOpen(), summary: () => cmdSummary(args[0]), @@ -525,8 +775,10 @@ if (!command || !commands[command]) { " fetch-content '' Fetch content for a location", " redact-body PATCH body with redacted file", " delete-comment Delete a comment", + " delete-discussion-comment Delete a discussion comment (GraphQL)", " recreate-comment Create replacement comment", - " notify Post notification", + " recreate-discussion-comment [reply-to-node-id] Create discussion comment (GraphQL)", + " notify [reply-to-node-id] Post notification", " resolve [resolution] [comment] Close alert", " list-open List open alerts", " summary Print formatted summary",