mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
fix(skills): preserve discussion comment replies
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -61,6 +61,7 @@ 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
|
||||
|
||||
@@ -118,10 +119,10 @@ For discussion comments (uses GraphQL):
|
||||
node secret-scanning.mjs delete-discussion-comment <COMMENT_NODE_ID>
|
||||
|
||||
# Recreate with redacted content
|
||||
node secret-scanning.mjs recreate-discussion-comment <DISCUSSION_NODE_ID> <body-file>
|
||||
node secret-scanning.mjs recreate-discussion-comment <DISCUSSION_NODE_ID> <body-file> [REPLY_TO_NODE_ID]
|
||||
```
|
||||
|
||||
The `fetch-content` output for `discussion_comment` includes `comment_node_id` and `discussion_node_id` for these commands.
|
||||
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:
|
||||
|
||||
@@ -154,11 +155,12 @@ Cannot clean. Notify author to delete branch or force-push (for unmerged PRs).
|
||||
## Step 5: Notify
|
||||
|
||||
```bash
|
||||
node secret-scanning.mjs notify <TARGET> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES>
|
||||
node secret-scanning.mjs notify <TARGET> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES> [REPLY_TO_NODE_ID]
|
||||
```
|
||||
|
||||
- For non-discussion types, `<TARGET>` is the issue/PR number.
|
||||
- For `discussion_comment`, `<TARGET>` 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"`
|
||||
|
||||
|
||||
@@ -42,6 +42,145 @@ function ghGraphQL(query, options = {}) {
|
||||
return gh(["api", "graphql", "-f", `query=${query}`], options);
|
||||
}
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
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);
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -94,62 +233,15 @@ function cmdFetchContent(locationJson) {
|
||||
const details = location.details;
|
||||
|
||||
if (type === "discussion_comment") {
|
||||
// Discussion comments can only be operated via GraphQL
|
||||
const commentUrl = details.discussion_comment_url;
|
||||
if (!commentUrl) fail("No discussion_comment_url in location details");
|
||||
|
||||
// Extract discussion number and comment id from URL
|
||||
// Format: https://github.com/owner/repo/discussions/123#discussioncomment-456
|
||||
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];
|
||||
|
||||
// Fetch discussion comment via GraphQL with pagination
|
||||
const [owner, name] = REPO.split("/");
|
||||
let comment = null;
|
||||
let discussionId = null;
|
||||
let cursor = null;
|
||||
let hasNextPage = true;
|
||||
|
||||
while (hasNextPage && !comment) {
|
||||
const afterClause = cursor ? `, after: "${cursor}"` : "";
|
||||
const gql = ghGraphQL(
|
||||
`{
|
||||
repository(owner: "${owner}", name: "${name}") {
|
||||
discussion(number: ${discussionNumber}) {
|
||||
id
|
||||
url
|
||||
comments(first: 50${afterClause}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
author { login }
|
||||
body
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ allowFailure: true },
|
||||
);
|
||||
|
||||
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;
|
||||
comment = discussion.comments.nodes.find(
|
||||
(c) => String(c.databaseId) === discussionCommentDbId,
|
||||
);
|
||||
hasNextPage = discussion.comments.pageInfo.hasNextPage;
|
||||
cursor = discussion.comments.pageInfo.endCursor;
|
||||
}
|
||||
|
||||
const { discussionId, comment } = fetchDiscussionComment(discussionNumber, discussionCommentDbId);
|
||||
if (!comment)
|
||||
fail(
|
||||
`Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`,
|
||||
@@ -164,10 +256,12 @@ function cmdFetchContent(locationJson) {
|
||||
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,
|
||||
@@ -375,28 +469,16 @@ function cmdDeleteDiscussionComment(nodeId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* recreate-discussion-comment <discussion-node-id> <body-file>
|
||||
* recreate-discussion-comment <discussion-node-id> <body-file> [reply-to-node-id]
|
||||
* Create a new discussion comment via GraphQL.
|
||||
*/
|
||||
function cmdRecreateDiscussionComment(discussionNodeId, bodyFile) {
|
||||
function cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId) {
|
||||
if (!discussionNodeId || !bodyFile)
|
||||
fail("Usage: recreate-discussion-comment <discussion-node-id> <body-file>");
|
||||
fail("Usage: recreate-discussion-comment <discussion-node-id> <body-file> [reply-to-node-id]");
|
||||
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
|
||||
|
||||
const body = fs.readFileSync(bodyFile, "utf8");
|
||||
// Escape for GraphQL string literal
|
||||
const escapedBody = body
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\n/g, "\\n");
|
||||
const result = ghGraphQL(
|
||||
`mutation { addDiscussionComment(input: { discussionId: "${discussionNodeId}", body: "${escapedBody}" }) { comment { id url } } }`,
|
||||
);
|
||||
if (result?.errors) {
|
||||
fail(`Failed to create discussion comment: ${JSON.stringify(result.errors)}`);
|
||||
}
|
||||
const newComment = result?.data?.addDiscussionComment?.comment;
|
||||
const newComment = createDiscussionComment(discussionNodeId, body, replyToNodeId);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
@@ -433,13 +515,15 @@ function cmdRecreateComment(issueNumber, bodyFile) {
|
||||
}
|
||||
|
||||
/**
|
||||
* notify <target> <author> <location-type> <secret-types>
|
||||
* notify <target> <author> <location-type> <secret-types> [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(target, author, locationType, secretTypes) {
|
||||
function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {
|
||||
if (!target || !author || !locationType || !secretTypes) {
|
||||
fail("Usage: notify <target> <author> <location-type> <secret-types-comma-sep>");
|
||||
fail(
|
||||
"Usage: notify <target> <author> <location-type> <secret-types-comma-sep> [reply-to-node-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const types = secretTypes.split(",").map((s) => s.trim());
|
||||
@@ -487,18 +571,7 @@ function cmdNotify(target, author, locationType, secretTypes) {
|
||||
|
||||
// Discussion comments must be notified via GraphQL
|
||||
if (locationType === "discussion_comment") {
|
||||
const escapedBody = body
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\n/g, "\\n");
|
||||
const result = ghGraphQL(
|
||||
`mutation { addDiscussionComment(input: { discussionId: "${target}", body: "${escapedBody}" }) { comment { id url } } }`,
|
||||
);
|
||||
if (result?.errors) {
|
||||
fail(`Failed to post discussion notification: ${JSON.stringify(result.errors)}`);
|
||||
}
|
||||
const newComment = result?.data?.addDiscussionComment?.comment;
|
||||
const newComment = createDiscussionComment(target, body, replyToNodeId);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
@@ -665,8 +738,8 @@ const commands = {
|
||||
"delete-comment": () => cmdDeleteComment(args[0]),
|
||||
"delete-discussion-comment": () => cmdDeleteDiscussionComment(args[0]),
|
||||
"recreate-comment": () => cmdRecreateComment(args[0], args[1]),
|
||||
"recreate-discussion-comment": () => cmdRecreateDiscussionComment(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]),
|
||||
@@ -684,8 +757,8 @@ if (!command || !commands[command]) {
|
||||
" delete-comment <comment-id> Delete a comment",
|
||||
" delete-discussion-comment <node-id> Delete a discussion comment (GraphQL)",
|
||||
" recreate-comment <issue-n> <file> Create replacement comment",
|
||||
" recreate-discussion-comment <disc-node-id> <file> Create discussion comment (GraphQL)",
|
||||
" notify <target> <author> <type> <types> Post notification",
|
||||
" recreate-discussion-comment <disc-node-id> <file> [reply-to-node-id] Create discussion comment (GraphQL)",
|
||||
" notify <target> <author> <type> <types> [reply-to-node-id] Post notification",
|
||||
" resolve <n> [resolution] [comment] Close alert",
|
||||
" list-open List open alerts",
|
||||
" summary <json-file> Print formatted summary",
|
||||
|
||||
Reference in New Issue
Block a user