diff --git a/extensions/cli/package-lock.json b/extensions/cli/package-lock.json index b86896f4f0a..34d2ce35145 100644 --- a/extensions/cli/package-lock.json +++ b/extensions/cli/package-lock.json @@ -120,9 +120,9 @@ "license": "Apache-2.0", "dependencies": { "@anthropic-ai/sdk": "^0.62.0", - "@aws-sdk/client-bedrock-runtime": "^3.779.0", + "@aws-sdk/client-bedrock-runtime": "^3.931.0", "@aws-sdk/client-sagemaker-runtime": "^3.777.0", - "@aws-sdk/credential-providers": "^3.778.0", + "@aws-sdk/credential-providers": "^3.931.0", "@continuedev/config-types": "^1.0.13", "@continuedev/config-yaml": "file:../packages/config-yaml", "@continuedev/fetch": "file:../packages/fetch", @@ -275,8 +275,8 @@ "@ai-sdk/anthropic": "^1.0.10", "@ai-sdk/openai": "^1.0.10", "@anthropic-ai/sdk": "^0.67.0", - "@aws-sdk/client-bedrock-runtime": "^3.929.0", - "@aws-sdk/credential-providers": "^3.929.0", + "@aws-sdk/client-bedrock-runtime": "^3.931.0", + "@aws-sdk/credential-providers": "^3.931.0", "@continuedev/config-types": "^1.0.14", "@continuedev/config-yaml": "^1.36.0", "@continuedev/fetch": "^1.6.0", @@ -520,7 +520,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -544,7 +543,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1809,7 +1807,6 @@ "integrity": "sha512-oNXsh2ywth5aowwIa7RKtawnkdH6LgU1ztfP9AIUCQCvzysB+WeU8o2kyyosDPwBZutPpjZDKPQGIzzrfTWweQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.1", @@ -1980,7 +1977,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2002,7 +1998,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.0.1.tgz", "integrity": "sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2015,7 +2010,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2257,7 +2251,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.203.0.tgz", "integrity": "sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.203.0", "import-in-the-middle": "^1.8.1", @@ -3574,7 +3567,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3663,7 +3655,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -3699,7 +3690,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.36.0.tgz", "integrity": "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -5110,7 +5100,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/body-parser": { "version": "1.19.6", @@ -5398,7 +5389,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -5468,7 +5458,6 @@ "integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5590,7 +5579,6 @@ "integrity": "sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.40.0", @@ -5621,7 +5609,6 @@ "integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.40.0", "@typescript-eslint/types": "8.40.0", @@ -6222,7 +6209,6 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -6303,7 +6289,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6511,6 +6496,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -8384,7 +8370,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dot-prop": { "version": "5.3.0", @@ -8925,7 +8912,6 @@ "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -9097,7 +9083,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9545,7 +9530,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -12142,6 +12126,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -12169,7 +12154,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -15077,7 +15061,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15992,7 +15975,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16214,7 +16196,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -16318,6 +16299,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -16333,6 +16315,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -16561,7 +16544,6 @@ "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16572,6 +16554,7 @@ "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16584,14 +16567,16 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-reconciler": { "version": "0.32.0", @@ -17083,7 +17068,6 @@ "integrity": "sha512-g7RssbTAbir1k/S7uSwSVZFfFXwpomUB9Oas0+xi9KStSCmeDXcA7rNhiskjLqvUe/Evhx8fVCT16OSa34eM5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", @@ -18616,7 +18600,6 @@ "integrity": "sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -18766,7 +18749,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18886,7 +18868,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -19000,7 +18981,6 @@ "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -19099,7 +19079,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -19635,7 +19614,6 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -19710,7 +19688,6 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -19840,7 +19817,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/extensions/cli/src/telemetry/utils.test.ts b/extensions/cli/src/telemetry/utils.test.ts new file mode 100644 index 00000000000..117259542a0 --- /dev/null +++ b/extensions/cli/src/telemetry/utils.test.ts @@ -0,0 +1,242 @@ +import { + isGitCommitCommand, + isPullRequestCommand, + isCommentCommand, + isGitPushCommand, + isIssueCloseCommand, + isReviewCommand, + isCommentReplyCommand, + isResolveThreadCommand, +} from "./utils.js"; + +describe("isGitCommitCommand", () => { + it("should detect git commit", () => { + expect(isGitCommitCommand("git commit -m 'message'")).toBe(true); + }); + + it("should detect git commit with flags", () => { + expect(isGitCommitCommand("git commit -am 'message'")).toBe(true); + }); + + it("should detect git-commit", () => { + expect(isGitCommitCommand("git-commit -m 'message'")).toBe(true); + }); + + it("should be case insensitive", () => { + expect(isGitCommitCommand("GIT COMMIT -m 'message'")).toBe(true); + }); + + it("should not match git add", () => { + expect(isGitCommitCommand("git add .")).toBe(false); + }); + + it("should not match git push", () => { + expect(isGitCommitCommand("git push origin main")).toBe(false); + }); +}); + +describe("isPullRequestCommand", () => { + it("should detect gh pr create", () => { + expect(isPullRequestCommand("gh pr create --title 'PR'")).toBe(true); + }); + + it("should detect hub pull-request", () => { + expect(isPullRequestCommand("hub pull-request -m 'PR'")).toBe(true); + }); + + it("should detect gitlab mr create", () => { + expect(isPullRequestCommand("gitlab mr create")).toBe(true); + }); + + it("should detect git push with pull-request flag", () => { + expect( + isPullRequestCommand("git push -u origin branch --pull-request"), + ).toBe(true); + }); + + it("should not match regular git push", () => { + expect(isPullRequestCommand("git push origin main")).toBe(false); + }); + + it("should not match gh pr view", () => { + expect(isPullRequestCommand("gh pr view 123")).toBe(false); + }); +}); + +describe("isCommentCommand", () => { + it("should detect gh pr comment", () => { + expect(isCommentCommand("gh pr comment 123 --body 'test'")).toBe(true); + }); + + it("should detect gh issue comment", () => { + expect(isCommentCommand("gh issue comment 456 --body 'test'")).toBe(true); + }); + + it("should be case insensitive", () => { + expect(isCommentCommand("GH PR COMMENT 123")).toBe(true); + }); + + it("should not match gh pr view", () => { + expect(isCommentCommand("gh pr view 123")).toBe(false); + }); + + it("should not match gh pr create", () => { + expect(isCommentCommand("gh pr create")).toBe(false); + }); +}); + +describe("isGitPushCommand", () => { + it("should detect git push", () => { + expect(isGitPushCommand("git push origin main")).toBe(true); + }); + + it("should detect git push with flags", () => { + expect(isGitPushCommand("git push -u origin feature")).toBe(true); + }); + + it("should not match git push with pull-request flag", () => { + expect(isGitPushCommand("git push --pull-request")).toBe(false); + }); + + it("should not match git commit", () => { + expect(isGitPushCommand("git commit -m 'msg'")).toBe(false); + }); + + it("should be case insensitive", () => { + expect(isGitPushCommand("GIT PUSH origin main")).toBe(true); + }); +}); + +describe("isIssueCloseCommand", () => { + it("should detect gh issue close", () => { + expect(isIssueCloseCommand("gh issue close 123")).toBe(true); + }); + + it("should detect with comment flag", () => { + expect(isIssueCloseCommand("gh issue close 123 --comment 'Done'")).toBe( + true, + ); + }); + + it("should be case insensitive", () => { + expect(isIssueCloseCommand("GH ISSUE CLOSE 456")).toBe(true); + }); + + it("should not match gh issue view", () => { + expect(isIssueCloseCommand("gh issue view 123")).toBe(false); + }); + + it("should not match gh issue create", () => { + expect(isIssueCloseCommand("gh issue create")).toBe(false); + }); +}); + +describe("isReviewCommand", () => { + it("should detect gh pr review", () => { + expect(isReviewCommand("gh pr review 123")).toBe(true); + }); + + it("should detect with --approve flag", () => { + expect(isReviewCommand("gh pr review 123 --approve")).toBe(true); + }); + + it("should detect with --request-changes flag", () => { + expect(isReviewCommand("gh pr review 123 --request-changes")).toBe(true); + }); + + it("should detect with --comment flag", () => { + expect(isReviewCommand("gh pr review 123 --comment --body 'test'")).toBe( + true, + ); + }); + + it("should be case insensitive", () => { + expect(isReviewCommand("GH PR REVIEW 456")).toBe(true); + }); + + it("should not match gh pr view", () => { + expect(isReviewCommand("gh pr view 123")).toBe(false); + }); + + it("should not match gh pr comment", () => { + expect(isReviewCommand("gh pr comment 123")).toBe(false); + }); +}); + +describe("isCommentReplyCommand", () => { + it("should detect gh api comment reply", () => { + const cmd = + "gh api -X POST repos/owner/repo/pulls/123/comments/456/replies -f body='test'"; + expect(isCommentReplyCommand(cmd)).toBe(true); + }); + + it("should handle different whitespace", () => { + const cmd = "gh api -X POST repos/org/project/pulls/1/comments/2/replies"; + expect(isCommentReplyCommand(cmd)).toBe(true); + }); + + it("should be case insensitive", () => { + const cmd = + "GH API -X POST repos/owner/repo/pulls/123/comments/456/replies"; + expect(isCommentReplyCommand(cmd)).toBe(true); + }); + + it("should not match regular gh api calls", () => { + expect(isCommentReplyCommand("gh api repos/owner/repo/pulls/123")).toBe( + false, + ); + }); + + it("should not match gh pr comment", () => { + expect(isCommentReplyCommand("gh pr comment 123 --body 'test'")).toBe( + false, + ); + }); + + it("should require full path pattern", () => { + // Missing comments/{id}/replies + expect( + isCommentReplyCommand("gh api -X POST repos/owner/repo/pulls/123"), + ).toBe(false); + }); + + it("should match with various repo names", () => { + const cmd = + "gh api -X POST repos/my-org/my-repo-name/pulls/999/comments/111/replies"; + expect(isCommentReplyCommand(cmd)).toBe(true); + }); +}); + +describe("isResolveThreadCommand", () => { + it("should detect gh api graphql resolveReviewThread", () => { + const cmd = + "gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: \"PRRT_xxx\"}) { thread { isResolved } } }'"; + expect(isResolveThreadCommand(cmd)).toBe(true); + }); + + it("should handle different query formats", () => { + const cmd = + 'gh api graphql --jq ".data" -f query="mutation { resolveReviewThread }"'; + expect(isResolveThreadCommand(cmd)).toBe(true); + }); + + it("should be case insensitive", () => { + const cmd = "GH API GRAPHQL -f query='resolveReviewThread'"; + expect(isResolveThreadCommand(cmd)).toBe(true); + }); + + it("should not match unresolveReviewThread", () => { + // This tests that we're matching resolveReviewThread specifically + // unresolveReviewThread should not match due to word boundary matching + const cmd = "gh api graphql -f query='mutation { unresolveReviewThread }'"; + expect(isResolveThreadCommand(cmd)).toBe(false); + }); + + it("should not match regular gh api calls", () => { + expect(isResolveThreadCommand("gh api repos/owner/repo/pulls")).toBe(false); + }); + + it("should not match gh pr commands", () => { + expect(isResolveThreadCommand("gh pr review 123 --approve")).toBe(false); + }); +}); diff --git a/extensions/cli/src/telemetry/utils.ts b/extensions/cli/src/telemetry/utils.ts index 54fb60f925c..f0e9b740a43 100644 --- a/extensions/cli/src/telemetry/utils.ts +++ b/extensions/cli/src/telemetry/utils.ts @@ -112,6 +112,58 @@ export function isPullRequestCommand(command: string): boolean { ); } +/** + * Check if a command is a comment command (PR or issue comment) + */ +export function isCommentCommand(command: string): boolean { + const trimmed = command.trim().toLowerCase(); + return ( + trimmed.includes("gh pr comment") || trimmed.includes("gh issue comment") + ); +} + +/** + * Check if a command is a git push command (but not PR creation) + */ +export function isGitPushCommand(command: string): boolean { + const trimmed = command.trim().toLowerCase(); + return trimmed.startsWith("git push") && !trimmed.includes("pull-request"); +} + +/** + * Check if a command is an issue close command + */ +export function isIssueCloseCommand(command: string): boolean { + const trimmed = command.trim().toLowerCase(); + return trimmed.includes("gh issue close"); +} + +/** + * Check if a command is a PR review command + */ +export function isReviewCommand(command: string): boolean { + const trimmed = command.trim().toLowerCase(); + return trimmed.includes("gh pr review"); +} + +/** + * Check if command is a gh api call to reply to a PR review comment + * Pattern: gh api -X POST repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies + */ +export function isCommentReplyCommand(command: string): boolean { + return /gh api\s+.*repos\/[^\/]+\/[^\/]+\/pulls\/\d+\/comments\/\d+\/replies/i.test( + command, + ); +} + +/** + * Check if command is a gh api graphql call to resolve a review thread + * Pattern: gh api graphql ... resolveReviewThread + */ +export function isResolveThreadCommand(command: string): boolean { + return /gh api graphql.*\bresolveReviewThread\b/i.test(command); +} + /** * Get file type from extension for metrics */ diff --git a/extensions/cli/src/tools/runTerminalCommand.ts b/extensions/cli/src/tools/runTerminalCommand.ts index ed4a2e49bbb..d2df598cc03 100644 --- a/extensions/cli/src/tools/runTerminalCommand.ts +++ b/extensions/cli/src/tools/runTerminalCommand.ts @@ -7,9 +7,27 @@ import { import { telemetryService } from "../telemetry/telemetryService.js"; import { + isCommentCommand, + isCommentReplyCommand, isGitCommitCommand, + isGitPushCommand, + isIssueCloseCommand, isPullRequestCommand, + isResolveThreadCommand, + isReviewCommand, } from "../telemetry/utils.js"; +import { + ParsedEventDetails, + parseCommentOutput, + parseCommentReplyOutput, + parseGitPushOutput, + parseIssueCloseOutput, + parsePrCreatedOutput, + parseResolveThreadOutput, + parseReviewOutput, +} from "../util/commandEventParser.js"; +import { getAgentIdFromArgs, postAgentEvent } from "../util/events.js"; +import { logger } from "../util/logger.js"; import { Tool } from "./types.js"; @@ -28,6 +46,45 @@ function getShellCommand(command: string): { shell: string; args: string[] } { } } +/** + * Emit an action event to the control plane for Activity Timeline. + * Non-blocking - errors are logged but don't fail the command. + */ +async function emitActionEvent( + agentId: string, + command: string, + output: string, +): Promise { + try { + let eventDetails: ParsedEventDetails | null = null; + + if (isPullRequestCommand(command)) { + eventDetails = parsePrCreatedOutput(output); + } else if (isCommentCommand(command)) { + eventDetails = parseCommentOutput(command, output); + } else if (isGitPushCommand(command)) { + eventDetails = parseGitPushOutput(output); + } else if (isIssueCloseCommand(command)) { + eventDetails = parseIssueCloseOutput(command); + } else if (isReviewCommand(command)) { + eventDetails = parseReviewOutput(command); + } else if (isCommentReplyCommand(command)) { + // gh api -X POST repos/.../pulls/.../comments/.../replies + eventDetails = parseCommentReplyOutput(command); + } else if (isResolveThreadCommand(command)) { + // gh api graphql ... resolveReviewThread + eventDetails = parseResolveThreadOutput(); + } + + if (eventDetails) { + await postAgentEvent(agentId, eventDetails); + } + } catch (error) { + // Non-blocking - log but don't fail the command + logger.debug("Failed to emit action event", error); + } +} + export const runTerminalCommandTool: Tool = { name: "Bash", displayName: "Bash", @@ -169,6 +226,12 @@ IMPORTANT: To edit files, use Edit/MultiEdit tools instead of bash commands (sed } else if (isPullRequestCommand(command)) { telemetryService.recordPullRequestCreated(); } + + // Emit activity events for Timeline (non-blocking) + const agentId = getAgentIdFromArgs(); + if (agentId) { + void emitActionEvent(agentId, command, stdout); + } } let output = stdout; diff --git a/extensions/cli/src/util/commandEventParser.test.ts b/extensions/cli/src/util/commandEventParser.test.ts new file mode 100644 index 00000000000..c869dbfcaa5 --- /dev/null +++ b/extensions/cli/src/util/commandEventParser.test.ts @@ -0,0 +1,298 @@ +import { + parsePrCreatedOutput, + parseCommentOutput, + parseGitPushOutput, + parseIssueCloseOutput, + parseReviewOutput, + parseCommentReplyOutput, + parseResolveThreadOutput, +} from "./commandEventParser.js"; + +describe("parsePrCreatedOutput", () => { + it("should parse gh pr create output with GitHub URL", () => { + const output = ` +Creating pull request for feature-branch into main in owner/repo + +https://github.com/owner/repo/pull/123 +`; + const result = parsePrCreatedOutput(output); + expect(result).toEqual({ + eventName: "pr_created", + title: "Created PR #123 in owner/repo", + externalUrl: "https://github.com/owner/repo/pull/123", + metadata: { owner: "owner", repo: "repo", prNumber: 123 }, + }); + }); + + it("should handle URL with different owner/repo names", () => { + const output = "https://github.com/continuedev/continue/pull/456"; + const result = parsePrCreatedOutput(output); + expect(result).toEqual({ + eventName: "pr_created", + title: "Created PR #456 in continuedev/continue", + externalUrl: "https://github.com/continuedev/continue/pull/456", + metadata: { owner: "continuedev", repo: "continue", prNumber: 456 }, + }); + }); + + it("should return null when no URL is found", () => { + const output = "Pull request created successfully"; + const result = parsePrCreatedOutput(output); + expect(result).toBeNull(); + }); + + it("should handle output with multiple lines and noise", () => { + const output = ` +? Title My PR Title +? Body + +Creating pull request for my-branch into main in myorg/myrepo + +https://github.com/myorg/myrepo/pull/789 +`; + const result = parsePrCreatedOutput(output); + expect(result?.metadata?.prNumber).toBe(789); + expect(result?.metadata?.owner).toBe("myorg"); + }); +}); + +describe("parseCommentOutput", () => { + it("should parse gh pr comment command", () => { + const command = "gh pr comment 123 --body 'LGTM!'"; + const output = ""; + const result = parseCommentOutput(command, output); + expect(result).toEqual({ + eventName: "comment_posted", + title: "Posted comment on PR #123", + externalUrl: undefined, + metadata: { prNumber: 123 }, + }); + }); + + it("should parse gh issue comment command", () => { + const command = "gh issue comment 456 --body 'Fixed in PR #789'"; + const output = ""; + const result = parseCommentOutput(command, output); + expect(result).toEqual({ + eventName: "comment_posted", + title: "Posted comment on issue #456", + externalUrl: undefined, + metadata: { issueNumber: 456 }, + }); + }); + + it("should extract external URL from output when present", () => { + const command = "gh pr comment 123 --body 'Done'"; + const output = + "https://github.com/owner/repo/pull/123#issuecomment-987654321"; + const result = parseCommentOutput(command, output); + expect(result?.externalUrl).toBe( + "https://github.com/owner/repo/pull/123#issuecomment-987654321", + ); + }); + + it("should return null for non-comment commands", () => { + const command = "gh pr view 123"; + const output = ""; + const result = parseCommentOutput(command, output); + expect(result).toBeNull(); + }); + + it("should handle case insensitive matching", () => { + const command = "GH PR COMMENT 999 --body 'test'"; + const output = ""; + const result = parseCommentOutput(command, output); + expect(result?.metadata?.prNumber).toBe(999); + }); +}); + +describe("parseGitPushOutput", () => { + it("should parse git push output with branch name", () => { + const output = ` +To github.com:owner/repo.git + abc123..def456 main -> main +`; + const result = parseGitPushOutput(output); + expect(result).toEqual({ + eventName: "commit_pushed", + title: "Pushed commits to main", + metadata: { + branch: "main", + repository: "owner/repo", + }, + }); + }); + + it("should parse new branch push", () => { + const output = ` +To github.com:owner/repo.git + * [new branch] feature-branch -> feature-branch +`; + const result = parseGitPushOutput(output); + expect(result?.title).toBe("Pushed commits to feature-branch"); + expect(result?.metadata?.branch).toBe("feature-branch"); + }); + + it("should return generic title when branch cannot be extracted", () => { + const output = "Everything up-to-date"; + const result = parseGitPushOutput(output); + expect(result).toEqual({ + eventName: "commit_pushed", + title: "Pushed commits", + metadata: { + branch: undefined, + repository: undefined, + }, + }); + }); + + it("should handle HTTPS remote URL", () => { + const output = ` +To https://github.com/owner/repo.git + 111111..222222 develop -> develop +`; + const result = parseGitPushOutput(output); + expect(result?.metadata?.branch).toBe("develop"); + }); +}); + +describe("parseIssueCloseOutput", () => { + it("should parse gh issue close command", () => { + const command = "gh issue close 42"; + const result = parseIssueCloseOutput(command); + expect(result).toEqual({ + eventName: "issue_closed", + title: "Closed issue #42", + metadata: { issueNumber: 42 }, + }); + }); + + it("should handle command with additional flags", () => { + const command = "gh issue close 123 --comment 'Closing as duplicate'"; + const result = parseIssueCloseOutput(command); + expect(result?.metadata?.issueNumber).toBe(123); + }); + + it("should return null for non-close commands", () => { + const command = "gh issue view 42"; + const result = parseIssueCloseOutput(command); + expect(result).toBeNull(); + }); + + it("should handle case insensitive matching", () => { + const command = "GH ISSUE CLOSE 789"; + const result = parseIssueCloseOutput(command); + expect(result?.metadata?.issueNumber).toBe(789); + }); +}); + +describe("parseReviewOutput", () => { + it("should parse gh pr review with --approve", () => { + const command = "gh pr review 123 --approve"; + const result = parseReviewOutput(command); + expect(result).toEqual({ + eventName: "review_submitted", + title: "Submitted approval on PR #123", + metadata: { prNumber: 123, reviewType: "approval" }, + }); + }); + + it("should parse gh pr review with --comment", () => { + const command = "gh pr review 456 --comment --body 'Needs work'"; + const result = parseReviewOutput(command); + expect(result).toEqual({ + eventName: "review_submitted", + title: "Submitted comment on PR #456", + metadata: { prNumber: 456, reviewType: "comment" }, + }); + }); + + it("should parse gh pr review with --request-changes", () => { + const command = "gh pr review 789 --request-changes --body 'Please fix'"; + const result = parseReviewOutput(command); + expect(result).toEqual({ + eventName: "review_submitted", + title: "Submitted changes requested on PR #789", + metadata: { prNumber: 789, reviewType: "changes requested" }, + }); + }); + + it("should default to generic review type when no flag specified", () => { + const command = "gh pr review 100"; + const result = parseReviewOutput(command); + expect(result?.metadata?.reviewType).toBe("review"); + }); + + it("should return null for non-review commands", () => { + const command = "gh pr view 123"; + const result = parseReviewOutput(command); + expect(result).toBeNull(); + }); +}); + +describe("parseCommentReplyOutput", () => { + it("should parse gh api comment reply command", () => { + const command = + 'gh api -X POST repos/owner/repo/pulls/123/comments/456/replies -f body="Thanks for the review"'; + const result = parseCommentReplyOutput(command); + expect(result).toEqual({ + eventName: "comment_reply_posted", + title: "Replied to comment on PR #123", + metadata: { + owner: "owner", + repo: "repo", + prNumber: 123, + commentId: 456, + }, + }); + }); + + it("should handle different owner/repo formats", () => { + const command = + "gh api -X POST repos/continuedev/continue/pulls/789/comments/999/replies -f body='Done'"; + const result = parseCommentReplyOutput(command); + expect(result?.metadata).toEqual({ + owner: "continuedev", + repo: "continue", + prNumber: 789, + commentId: 999, + }); + }); + + it("should handle command with extra whitespace", () => { + const command = + "gh api -X POST repos/org/project/pulls/1/comments/2/replies -f body='test'"; + const result = parseCommentReplyOutput(command); + expect(result?.metadata?.prNumber).toBe(1); + expect(result?.metadata?.commentId).toBe(2); + }); + + it("should return null for non-reply API calls", () => { + const command = "gh api repos/owner/repo/pulls/123"; + const result = parseCommentReplyOutput(command); + expect(result).toBeNull(); + }); + + it("should return null for regular gh commands", () => { + const command = "gh pr comment 123 --body 'test'"; + const result = parseCommentReplyOutput(command); + expect(result).toBeNull(); + }); +}); + +describe("parseResolveThreadOutput", () => { + it("should return a generic resolved event", () => { + const result = parseResolveThreadOutput(); + expect(result).toEqual({ + eventName: "review_thread_resolved", + title: "Resolved review thread", + metadata: {}, + }); + }); + + it("should always return the same structure", () => { + const result1 = parseResolveThreadOutput(); + const result2 = parseResolveThreadOutput(); + expect(result1).toEqual(result2); + }); +}); diff --git a/extensions/cli/src/util/commandEventParser.ts b/extensions/cli/src/util/commandEventParser.ts new file mode 100644 index 00000000000..90a715eba00 --- /dev/null +++ b/extensions/cli/src/util/commandEventParser.ts @@ -0,0 +1,173 @@ +/** + * Parse command output to extract event details for Activity Timeline + */ + +export interface ParsedEventDetails { + eventName: string; + title: string; + externalUrl?: string; + metadata?: Record; +} + +/** + * Parse output from `gh pr create` to extract PR details + */ +export function parsePrCreatedOutput( + output: string, +): ParsedEventDetails | null { + // gh pr create outputs URL like: https://github.com/owner/repo/pull/123 + const urlMatch = output.match( + /https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/, + ); + if (urlMatch) { + const [url, owner, repo, prNumber] = urlMatch; + return { + eventName: "pr_created", + title: `Created PR #${prNumber} in ${owner}/${repo}`, + externalUrl: url, + metadata: { owner, repo, prNumber: parseInt(prNumber) }, + }; + } + return null; +} + +/** + * Parse output from `gh pr comment` or `gh issue comment` + */ +export function parseCommentOutput( + command: string, + output: string, +): ParsedEventDetails | null { + // Extract PR/issue number from command: gh pr comment 123 or gh issue comment 456 + const prMatch = command.match(/gh pr comment (\d+)/i); + const issueMatch = command.match(/gh issue comment (\d+)/i); + + // Try to extract URL from output for externalUrl + const urlMatch = output.match( + /https:\/\/github\.com\/[^\/]+\/[^\/]+\/(pull|issues)\/\d+#issuecomment-\d+/, + ); + + if (prMatch) { + return { + eventName: "comment_posted", + title: `Posted comment on PR #${prMatch[1]}`, + externalUrl: urlMatch?.[0], + metadata: { prNumber: parseInt(prMatch[1]) }, + }; + } + if (issueMatch) { + return { + eventName: "comment_posted", + title: `Posted comment on issue #${issueMatch[1]}`, + externalUrl: urlMatch?.[0], + metadata: { issueNumber: parseInt(issueMatch[1]) }, + }; + } + return null; +} + +/** + * Parse output from `git push` + */ +export function parseGitPushOutput(output: string): ParsedEventDetails | null { + // git push output contains branch info like: + // To github.com:owner/repo.git + // * [new branch] feature -> feature + // or + // abc123..def456 main -> main + const branchMatch = output.match(/->\s+([^\s\]]+)/); + const repoMatch = output.match(/To (?:.*[:/])([^\/]+\/[^\.]+)/); + + return { + eventName: "commit_pushed", + title: branchMatch + ? `Pushed commits to ${branchMatch[1]}` + : "Pushed commits", + metadata: { + branch: branchMatch?.[1], + repository: repoMatch?.[1], + }, + }; +} + +/** + * Parse output from `gh issue close` + */ +export function parseIssueCloseOutput( + command: string, +): ParsedEventDetails | null { + const match = command.match(/gh issue close (\d+)/i); + if (match) { + return { + eventName: "issue_closed", + title: `Closed issue #${match[1]}`, + metadata: { issueNumber: parseInt(match[1]) }, + }; + } + return null; +} + +/** + * Parse output from `gh pr review` + */ +export function parseReviewOutput(command: string): ParsedEventDetails | null { + const prMatch = command.match(/gh pr review (\d+)/i); + // Check for review type flags: --approve, --comment, --request-changes + const approveMatch = command.match(/--approve/i); + const commentMatch = command.match(/--comment/i); + const requestChangesMatch = command.match(/--request-changes/i); + + if (prMatch) { + let reviewType = "review"; + if (approveMatch) reviewType = "approval"; + else if (requestChangesMatch) reviewType = "changes requested"; + else if (commentMatch) reviewType = "comment"; + + return { + eventName: "review_submitted", + title: `Submitted ${reviewType} on PR #${prMatch[1]}`, + metadata: { prNumber: parseInt(prMatch[1]), reviewType }, + }; + } + return null; +} + +/** + * Parse gh api comment reply command + * Pattern: gh api -X POST repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies -f body="..." + */ +export function parseCommentReplyOutput( + command: string, +): ParsedEventDetails | null { + // Extract: repos/owner/repo/pulls/123/comments/456/replies + const match = command.match( + /repos\/([^\/]+)\/([^\/]+)\/pulls\/(\d+)\/comments\/(\d+)\/replies/i, + ); + if (match) { + const [, owner, repo, prNumber, commentId] = match; + return { + eventName: "comment_reply_posted", + title: `Replied to comment on PR #${prNumber}`, + metadata: { + owner, + repo, + prNumber: parseInt(prNumber), + commentId: parseInt(commentId), + }, + }; + } + return null; +} + +/** + * Parse gh api graphql resolveReviewThread command + */ +export function parseResolveThreadOutput(): ParsedEventDetails | null { + // The threadId is in the graphql query, but we may not be able to extract PR number + // Just emit a generic event + return { + eventName: "review_thread_resolved", + title: "Resolved review thread", + metadata: {}, + }; +} diff --git a/extensions/cli/src/util/events.ts b/extensions/cli/src/util/events.ts new file mode 100644 index 00000000000..7ce000120b2 --- /dev/null +++ b/extensions/cli/src/util/events.ts @@ -0,0 +1,111 @@ +import { + post, + ApiRequestError, + AuthenticationRequiredError, +} from "./apiClient.js"; +import { logger } from "./logger.js"; + +/** + * Event types that can be emitted to the activity timeline + */ +export type ActionEventName = + | "comment_posted" + | "pr_created" + | "commit_pushed" + | "issue_closed" + | "review_submitted"; + +/** + * Parameters for emitting an activity event + */ +export interface EmitEventParams { + /** The type of action event */ + eventName: ActionEventName | string; + /** Human-readable title for the event */ + title: string; + /** Optional longer description */ + description?: string; + /** Optional event-specific metadata */ + metadata?: Record; + /** Optional external URL (e.g., link to GitHub PR or comment) */ + externalUrl?: string; +} + +/** + * Extract the agent ID from the --id command line flag + * @returns The agent ID or undefined if not found + */ +export function getAgentIdFromArgs(): string | undefined { + const args = process.argv; + const idIndex = args.indexOf("--id"); + if (idIndex !== -1 && idIndex + 1 < args.length) { + return args[idIndex + 1]; + } + return undefined; +} + +/** + * POST an activity event to the control plane for an agent session. + * Used to populate the Activity Timeline in the task detail view. + * + * @param agentId - The agent session ID + * @param params - Event parameters + * @returns The created event or undefined on failure + */ +export async function postAgentEvent( + agentId: string, + params: EmitEventParams, +): Promise | undefined> { + if (!agentId) { + logger.debug("No agent ID provided, skipping event emission"); + return undefined; + } + + if (!params.eventName || !params.title) { + logger.debug("Missing required event parameters, skipping event emission"); + return undefined; + } + + try { + logger.debug("Posting event to control plane", { + agentId, + eventName: params.eventName, + title: params.title, + }); + + const response = await post(`agents/${agentId}/events`, { + eventName: params.eventName, + title: params.title, + description: params.description, + metadata: params.metadata, + externalUrl: params.externalUrl, + }); + + if (response.ok) { + logger.info("Successfully posted event to control plane", { + eventName: params.eventName, + }); + return response.data; + } else { + logger.warn(`Unexpected response when posting event: ${response.status}`); + return undefined; + } + } catch (error) { + // Non-critical: Log but don't fail the entire agent execution + if (error instanceof AuthenticationRequiredError) { + logger.debug( + "Authentication required for event emission (skipping)", + error.message, + ); + } else if (error instanceof ApiRequestError) { + logger.warn( + `Failed to post event: ${error.status} ${error.response || error.statusText}`, + ); + } else { + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.warn(`Error posting event: ${errorMessage}`); + } + return undefined; + } +} diff --git a/package-lock.json b/package-lock.json index a4b2f971428..fa5611dbe04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -380,7 +380,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1260,7 +1259,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3472,7 +3470,6 @@ "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4412,7 +4409,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver"