diff --git a/.gitignore b/.gitignore index b947077..f345149 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ dist/ +old/ diff --git a/packages/github/package.json b/packages/github/package.json index 762a943..eefbd2b 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -26,7 +26,7 @@ "build": "tsup", "dev": "tsup --watch", "prepublishOnly": "pnpm run build", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "vitest" }, "keywords": [], "author": "amk-dev", diff --git a/packages/github/tools/.DS_Store b/packages/github/tools/.DS_Store new file mode 100644 index 0000000..55cee08 Binary files /dev/null and b/packages/github/tools/.DS_Store differ diff --git a/packages/github/tools/gists-create.test.ts b/packages/github/tools/gists-create.test.ts new file mode 100644 index 0000000..99f2bb5 --- /dev/null +++ b/packages/github/tools/gists-create.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { createGist } from "./gists-create"; + +const createGistFunction = createGist.function; + +const GITHUB_TOKEN = process.env.GITHUB_API_KEY; +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +describe("createGistFunction Integration Tests", () => { + const auth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + + it("should successfully create a private gist with minimal parameters", async () => { + const input = { + files: { + "test.txt": { content: "Hello, world!" }, + }, + }; + const result = await createGistFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(` + Ok { + "value": "{"url":"https://api.github.com/gists/19b8e33c731af408724f524bb2809257","forks_url":"https://api.github.com/gists/19b8e33c731af408724f524bb2809257/forks","commits_url":"https://api.github.com/gists/19b8e33c731af408724f524bb2809257/commits","id":"19b8e33c731af408724f524bb2809257","node_id":"G_kwDOA3FSRdoAIDE5YjhlMzNjNzMxYWY0MDg3MjRmNTI0YmIyODA5MjU3","git_pull_url":"https://gist.github.com/19b8e33c731af408724f524bb2809257.git","git_push_url":"https://gist.github.com/19b8e33c731af408724f524bb2809257.git","html_url":"https://gist.github.com/amk-dev/19b8e33c731af408724f524bb2809257","files":{"test.txt":{"filename":"test.txt","type":"text/plain","language":"Text","raw_url":"https://gist.githubusercontent.com/amk-dev/19b8e33c731af408724f524bb2809257/raw/5dd01c177f5d7d1be5346a5bc18a569a7410c2ef/test.txt","size":13,"truncated":false,"content":"Hello, world!","encoding":"utf-8"}},"public":false,"created_at":"2025-02-04T13:39:48Z","updated_at":"2025-02-04T13:39:48Z","description":null,"comments":0,"user":null,"comments_enabled":true,"comments_url":"https://api.github.com/gists/19b8e33c731af408724f524bb2809257/comments","owner":{"login":"amk-dev","id":57758277,"node_id":"MDQ6VXNlcjU3NzU4Mjc3","avatar_url":"https://avatars.githubusercontent.com/u/57758277?v=4","gravatar_id":"","url":"https://api.github.com/users/amk-dev","html_url":"https://github.com/amk-dev","followers_url":"https://api.github.com/users/amk-dev/followers","following_url":"https://api.github.com/users/amk-dev/following{/other_user}","gists_url":"https://api.github.com/users/amk-dev/gists{/gist_id}","starred_url":"https://api.github.com/users/amk-dev/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/amk-dev/subscriptions","organizations_url":"https://api.github.com/users/amk-dev/orgs","repos_url":"https://api.github.com/users/amk-dev/repos","events_url":"https://api.github.com/users/amk-dev/events{/privacy}","received_events_url":"https://api.github.com/users/amk-dev/received_events","type":"User","user_view_type":"public","site_admin":false},"forks":[],"history":[{"user":{"login":"amk-dev","id":57758277,"node_id":"MDQ6VXNlcjU3NzU4Mjc3","avatar_url":"https://avatars.githubusercontent.com/u/57758277?v=4","gravatar_id":"","url":"https://api.github.com/users/amk-dev","html_url":"https://github.com/amk-dev","followers_url":"https://api.github.com/users/amk-dev/followers","following_url":"https://api.github.com/users/amk-dev/following{/other_user}","gists_url":"https://api.github.com/users/amk-dev/gists{/gist_id}","starred_url":"https://api.github.com/users/amk-dev/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/amk-dev/subscriptions","organizations_url":"https://api.github.com/users/amk-dev/orgs","repos_url":"https://api.github.com/users/amk-dev/repos","events_url":"https://api.github.com/users/amk-dev/events{/privacy}","received_events_url":"https://api.github.com/users/amk-dev/received_events","type":"User","user_view_type":"public","site_admin":false},"version":"9803658ac2202c9ee58691a4f4440f260ac45c24","committed_at":"2025-02-04T13:39:47Z","change_status":{"total":1,"additions":1,"deletions":0},"url":"https://api.github.com/gists/19b8e33c731af408724f524bb2809257/9803658ac2202c9ee58691a4f4440f260ac45c24"}],"truncated":false}", + } + `); + }); + + it("should successfully create a public gist with public as string 'true'", async () => { + const input = { + description: "Public gist created using string value", + files: { + "public.txt": { content: "This is a public gist" }, + }, + public: "true", + }; + const result = await createGistFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(` + Ok { + "value": "{"url":"https://api.github.com/gists/3fe8d5a447cb84d762c44ea7dd068948","forks_url":"https://api.github.com/gists/3fe8d5a447cb84d762c44ea7dd068948/forks","commits_url":"https://api.github.com/gists/3fe8d5a447cb84d762c44ea7dd068948/commits","id":"3fe8d5a447cb84d762c44ea7dd068948","node_id":"G_kwDOA3FSRdoAIDNmZThkNWE0NDdjYjg0ZDc2MmM0NGVhN2RkMDY4OTQ4","git_pull_url":"https://gist.github.com/3fe8d5a447cb84d762c44ea7dd068948.git","git_push_url":"https://gist.github.com/3fe8d5a447cb84d762c44ea7dd068948.git","html_url":"https://gist.github.com/amk-dev/3fe8d5a447cb84d762c44ea7dd068948","files":{"public.txt":{"filename":"public.txt","type":"text/plain","language":"Text","raw_url":"https://gist.githubusercontent.com/amk-dev/3fe8d5a447cb84d762c44ea7dd068948/raw/927226924e98a52b0495067ceced5f7028175012/public.txt","size":21,"truncated":false,"content":"This is a public gist","encoding":"utf-8"}},"public":true,"created_at":"2025-02-04T13:39:48Z","updated_at":"2025-02-04T13:39:49Z","description":"Public gist created using string value","comments":0,"user":null,"comments_enabled":true,"comments_url":"https://api.github.com/gists/3fe8d5a447cb84d762c44ea7dd068948/comments","owner":{"login":"amk-dev","id":57758277,"node_id":"MDQ6VXNlcjU3NzU4Mjc3","avatar_url":"https://avatars.githubusercontent.com/u/57758277?v=4","gravatar_id":"","url":"https://api.github.com/users/amk-dev","html_url":"https://github.com/amk-dev","followers_url":"https://api.github.com/users/amk-dev/followers","following_url":"https://api.github.com/users/amk-dev/following{/other_user}","gists_url":"https://api.github.com/users/amk-dev/gists{/gist_id}","starred_url":"https://api.github.com/users/amk-dev/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/amk-dev/subscriptions","organizations_url":"https://api.github.com/users/amk-dev/orgs","repos_url":"https://api.github.com/users/amk-dev/repos","events_url":"https://api.github.com/users/amk-dev/events{/privacy}","received_events_url":"https://api.github.com/users/amk-dev/received_events","type":"User","user_view_type":"public","site_admin":false},"forks":[],"history":[{"user":{"login":"amk-dev","id":57758277,"node_id":"MDQ6VXNlcjU3NzU4Mjc3","avatar_url":"https://avatars.githubusercontent.com/u/57758277?v=4","gravatar_id":"","url":"https://api.github.com/users/amk-dev","html_url":"https://github.com/amk-dev","followers_url":"https://api.github.com/users/amk-dev/followers","following_url":"https://api.github.com/users/amk-dev/following{/other_user}","gists_url":"https://api.github.com/users/amk-dev/gists{/gist_id}","starred_url":"https://api.github.com/users/amk-dev/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/amk-dev/subscriptions","organizations_url":"https://api.github.com/users/amk-dev/orgs","repos_url":"https://api.github.com/users/amk-dev/repos","events_url":"https://api.github.com/users/amk-dev/events{/privacy}","received_events_url":"https://api.github.com/users/amk-dev/received_events","type":"User","user_view_type":"public","site_admin":false},"version":"b312fbd6f7659f75017149a4d11a8788d3d90d34","committed_at":"2025-02-04T13:39:48Z","change_status":{"total":1,"additions":1,"deletions":0},"url":"https://api.github.com/gists/3fe8d5a447cb84d762c44ea7dd068948/b312fbd6f7659f75017149a4d11a8788d3d90d34"}],"truncated":false}", + } + `); + }); + + it("should successfully create a public gist with public as boolean true", async () => { + const input = { + description: "Public gist created using boolean value", + files: { + "public2.txt": { content: "This is another public gist" }, + }, + public: true, + }; + const result = await createGistFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(` + Ok { + "value": "{"url":"https://api.github.com/gists/b8e5589f2a7babce14fc89c3dfa997af","forks_url":"https://api.github.com/gists/b8e5589f2a7babce14fc89c3dfa997af/forks","commits_url":"https://api.github.com/gists/b8e5589f2a7babce14fc89c3dfa997af/commits","id":"b8e5589f2a7babce14fc89c3dfa997af","node_id":"G_kwDOA3FSRdoAIGI4ZTU1ODlmMmE3YmFiY2UxNGZjODljM2RmYTk5N2Fm","git_pull_url":"https://gist.github.com/b8e5589f2a7babce14fc89c3dfa997af.git","git_push_url":"https://gist.github.com/b8e5589f2a7babce14fc89c3dfa997af.git","html_url":"https://gist.github.com/amk-dev/b8e5589f2a7babce14fc89c3dfa997af","files":{"public2.txt":{"filename":"public2.txt","type":"text/plain","language":"Text","raw_url":"https://gist.githubusercontent.com/amk-dev/b8e5589f2a7babce14fc89c3dfa997af/raw/884af919b8e864401e10ad66f2de9db1197af7bf/public2.txt","size":27,"truncated":false,"content":"This is another public gist","encoding":"utf-8"}},"public":true,"created_at":"2025-02-04T13:39:49Z","updated_at":"2025-02-04T13:39:49Z","description":"Public gist created using boolean value","comments":0,"user":null,"comments_enabled":true,"comments_url":"https://api.github.com/gists/b8e5589f2a7babce14fc89c3dfa997af/comments","owner":{"login":"amk-dev","id":57758277,"node_id":"MDQ6VXNlcjU3NzU4Mjc3","avatar_url":"https://avatars.githubusercontent.com/u/57758277?v=4","gravatar_id":"","url":"https://api.github.com/users/amk-dev","html_url":"https://github.com/amk-dev","followers_url":"https://api.github.com/users/amk-dev/followers","following_url":"https://api.github.com/users/amk-dev/following{/other_user}","gists_url":"https://api.github.com/users/amk-dev/gists{/gist_id}","starred_url":"https://api.github.com/users/amk-dev/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/amk-dev/subscriptions","organizations_url":"https://api.github.com/users/amk-dev/orgs","repos_url":"https://api.github.com/users/amk-dev/repos","events_url":"https://api.github.com/users/amk-dev/events{/privacy}","received_events_url":"https://api.github.com/users/amk-dev/received_events","type":"User","user_view_type":"public","site_admin":false},"forks":[],"history":[{"user":{"login":"amk-dev","id":57758277,"node_id":"MDQ6VXNlcjU3NzU4Mjc3","avatar_url":"https://avatars.githubusercontent.com/u/57758277?v=4","gravatar_id":"","url":"https://api.github.com/users/amk-dev","html_url":"https://github.com/amk-dev","followers_url":"https://api.github.com/users/amk-dev/followers","following_url":"https://api.github.com/users/amk-dev/following{/other_user}","gists_url":"https://api.github.com/users/amk-dev/gists{/gist_id}","starred_url":"https://api.github.com/users/amk-dev/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/amk-dev/subscriptions","organizations_url":"https://api.github.com/users/amk-dev/orgs","repos_url":"https://api.github.com/users/amk-dev/repos","events_url":"https://api.github.com/users/amk-dev/events{/privacy}","received_events_url":"https://api.github.com/users/amk-dev/received_events","type":"User","user_view_type":"public","site_admin":false},"version":"6d0681c66ff7a48a941b6d01355c506d74fc3e81","committed_at":"2025-02-04T13:39:49Z","change_status":{"total":1,"additions":1,"deletions":0},"url":"https://api.github.com/gists/b8e5589f2a7babce14fc89c3dfa997af/6d0681c66ff7a48a941b6d01355c506d74fc3e81"}],"truncated":false}", + } + `); + }); + + it("should create a gist with multiple files", async () => { + const input = { + description: "Multi-file gist test", + files: { + "file1.txt": { content: "Content of file 1" }, + "file2.txt": { content: "Content of file 2" }, + }, + }; + const result = await createGistFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(` + Ok { + "value": "{"url":"https://api.github.com/gists/c752e5395519e78ab35e9349c733ab1e","forks_url":"https://api.github.com/gists/c752e5395519e78ab35e9349c733ab1e/forks","commits_url":"https://api.github.com/gists/c752e5395519e78ab35e9349c733ab1e/commits","id":"c752e5395519e78ab35e9349c733ab1e","node_id":"G_kwDOA3FSRdoAIGM3NTJlNTM5NTUxOWU3OGFiMzVlOTM0OWM3MzNhYjFl","git_pull_url":"https://gist.github.com/c752e5395519e78ab35e9349c733ab1e.git","git_push_url":"https://gist.github.com/c752e5395519e78ab35e9349c733ab1e.git","html_url":"https://gist.github.com/amk-dev/c752e5395519e78ab35e9349c733ab1e","files":{"file1.txt":{"filename":"file1.txt","type":"text/plain","language":"Text","raw_url":"https://gist.githubusercontent.com/amk-dev/c752e5395519e78ab35e9349c733ab1e/raw/5728ca8d443090db34b0350fa64a723d35732aa6/file1.txt","size":17,"truncated":false,"content":"Content of file 1","encoding":"utf-8"},"file2.txt":{"filename":"file2.txt","type":"text/plain","language":"Text","raw_url":"https://gist.githubusercontent.com/amk-dev/c752e5395519e78ab35e9349c733ab1e/raw/b0df1fb85cbbf34cb3d97063c8ec1b360b98c832/file2.txt","size":17,"truncated":false,"content":"Content of file 2","encoding":"utf-8"}},"public":false,"created_at":"2025-02-04T13:39:50Z","updated_at":"2025-02-04T13:39:50Z","description":"Multi-file gist test","comments":0,"user":null,"comments_enabled":true,"comments_url":"https://api.github.com/gists/c752e5395519e78ab35e9349c733ab1e/comments","owner":{"login":"amk-dev","id":57758277,"node_id":"MDQ6VXNlcjU3NzU4Mjc3","avatar_url":"https://avatars.githubusercontent.com/u/57758277?v=4","gravatar_id":"","url":"https://api.github.com/users/amk-dev","html_url":"https://github.com/amk-dev","followers_url":"https://api.github.com/users/amk-dev/followers","following_url":"https://api.github.com/users/amk-dev/following{/other_user}","gists_url":"https://api.github.com/users/amk-dev/gists{/gist_id}","starred_url":"https://api.github.com/users/amk-dev/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/amk-dev/subscriptions","organizations_url":"https://api.github.com/users/amk-dev/orgs","repos_url":"https://api.github.com/users/amk-dev/repos","events_url":"https://api.github.com/users/amk-dev/events{/privacy}","received_events_url":"https://api.github.com/users/amk-dev/received_events","type":"User","user_view_type":"public","site_admin":false},"forks":[],"history":[{"user":{"login":"amk-dev","id":57758277,"node_id":"MDQ6VXNlcjU3NzU4Mjc3","avatar_url":"https://avatars.githubusercontent.com/u/57758277?v=4","gravatar_id":"","url":"https://api.github.com/users/amk-dev","html_url":"https://github.com/amk-dev","followers_url":"https://api.github.com/users/amk-dev/followers","following_url":"https://api.github.com/users/amk-dev/following{/other_user}","gists_url":"https://api.github.com/users/amk-dev/gists{/gist_id}","starred_url":"https://api.github.com/users/amk-dev/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/amk-dev/subscriptions","organizations_url":"https://api.github.com/users/amk-dev/orgs","repos_url":"https://api.github.com/users/amk-dev/repos","events_url":"https://api.github.com/users/amk-dev/events{/privacy}","received_events_url":"https://api.github.com/users/amk-dev/received_events","type":"User","user_view_type":"public","site_admin":false},"version":"c1c1dd758eab36e241dbdb2a13fe1ea70ac702b5","committed_at":"2025-02-04T13:39:50Z","change_status":{"total":2,"additions":2,"deletions":0},"url":"https://api.github.com/gists/c752e5395519e78ab35e9349c733ab1e/c1c1dd758eab36e241dbdb2a13fe1ea70ac702b5"}],"truncated":false}", + } + `); + }); + + it("should handle error with an invalid auth token", async () => { + const input = { + files: { + "invalid.txt": { content: "This should not be created" }, + }, + }; + const invalidAuth = { type: "Bearer", apiKey: "invalid-token" }; + const result = await createGistFunction(input, { auth: invalidAuth }); + expect(result).toMatchInlineSnapshot(` + Err { + "error": [FetchError: [POST] "https://api.github.com/gists": 401 Unauthorized], + } + `); + }); + + it("should handle error when files field is empty", async () => { + const input = { + description: "Empty files test", + files: {}, + }; + const result = await createGistFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(` + Err { + "error": [FetchError: [POST] "https://api.github.com/gists": 422 Unprocessable Entity], + } + `); + }); + + it("should successfully create a gist with a very long description", async () => { + const longDescription = "Long description ".repeat(20); + const input = { + description: longDescription, + files: { + "long.txt": { content: "Content for gist with a long description" }, + }, + }; + const result = await createGistFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(` + Ok { + "value": "{"url":"https://api.github.com/gists/d7b7cc7785f07f111f092bd3085c326d","forks_url":"https://api.github.com/gists/d7b7cc7785f07f111f092bd3085c326d/forks","commits_url":"https://api.github.com/gists/d7b7cc7785f07f111f092bd3085c326d/commits","id":"d7b7cc7785f07f111f092bd3085c326d","node_id":"G_kwDOA3FSRdoAIGQ3YjdjYzc3ODVmMDdmMTExZjA5MmJkMzA4NWMzMjZk","git_pull_url":"https://gist.github.com/d7b7cc7785f07f111f092bd3085c326d.git","git_push_url":"https://gist.github.com/d7b7cc7785f07f111f092bd3085c326d.git","html_url":"https://gist.github.com/amk-dev/d7b7cc7785f07f111f092bd3085c326d","files":{"long.txt":{"filename":"long.txt","type":"text/plain","language":"Text","raw_url":"https://gist.githubusercontent.com/amk-dev/d7b7cc7785f07f111f092bd3085c326d/raw/b45b4989b8e2633d7a039aa9fd5db84b92b8fa5d/long.txt","size":40,"truncated":false,"content":"Content for gist with a long description","encoding":"utf-8"}},"public":false,"created_at":"2025-02-04T13:39:52Z","updated_at":"2025-02-04T13:39:52Z","description":"Long description Long description Long description Long description Long description Long description Long description Long description Long description Long description Long description Long description Long description Long description Long description Long description Long description Long description Long description Long description ","comments":0,"user":null,"comments_enabled":true,"comments_url":"https://api.github.com/gists/d7b7cc7785f07f111f092bd3085c326d/comments","owner":{"login":"amk-dev","id":57758277,"node_id":"MDQ6VXNlcjU3NzU4Mjc3","avatar_url":"https://avatars.githubusercontent.com/u/57758277?v=4","gravatar_id":"","url":"https://api.github.com/users/amk-dev","html_url":"https://github.com/amk-dev","followers_url":"https://api.github.com/users/amk-dev/followers","following_url":"https://api.github.com/users/amk-dev/following{/other_user}","gists_url":"https://api.github.com/users/amk-dev/gists{/gist_id}","starred_url":"https://api.github.com/users/amk-dev/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/amk-dev/subscriptions","organizations_url":"https://api.github.com/users/amk-dev/orgs","repos_url":"https://api.github.com/users/amk-dev/repos","events_url":"https://api.github.com/users/amk-dev/events{/privacy}","received_events_url":"https://api.github.com/users/amk-dev/received_events","type":"User","user_view_type":"public","site_admin":false},"forks":[],"history":[{"user":{"login":"amk-dev","id":57758277,"node_id":"MDQ6VXNlcjU3NzU4Mjc3","avatar_url":"https://avatars.githubusercontent.com/u/57758277?v=4","gravatar_id":"","url":"https://api.github.com/users/amk-dev","html_url":"https://github.com/amk-dev","followers_url":"https://api.github.com/users/amk-dev/followers","following_url":"https://api.github.com/users/amk-dev/following{/other_user}","gists_url":"https://api.github.com/users/amk-dev/gists{/gist_id}","starred_url":"https://api.github.com/users/amk-dev/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/amk-dev/subscriptions","organizations_url":"https://api.github.com/users/amk-dev/orgs","repos_url":"https://api.github.com/users/amk-dev/repos","events_url":"https://api.github.com/users/amk-dev/events{/privacy}","received_events_url":"https://api.github.com/users/amk-dev/received_events","type":"User","user_view_type":"public","site_admin":false},"version":"8fe9218b8307e9f539fb7f9b99353c1485faae17","committed_at":"2025-02-04T13:39:52Z","change_status":{"total":1,"additions":1,"deletions":0},"url":"https://api.github.com/gists/d7b7cc7785f07f111f092bd3085c326d/8fe9218b8307e9f539fb7f9b99353c1485faae17"}],"truncated":false}", + } + `); + }); +}); diff --git a/packages/github/tools/gists-create.ts b/packages/github/tools/gists-create.ts new file mode 100644 index 0000000..b971d8c --- /dev/null +++ b/packages/github/tools/gists-create.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import type { ToolsetteTool } from "@toolsette/utils"; + +// Schema for creating a gist based on the OpenAPI specification +const createGistSchema = z.object({ + description: z + .string() + .optional() + .describe("Description of the gist (e.g. 'Example Ruby script')"), + files: z + .record( + z.object({ + content: z.string().describe("Content of the file"), + }) + ) + .describe( + "Names and content for the files that make up the gist. Example: { 'hello.rb': { content: 'puts \"Hello, World!\"' } }" + ), + public: z + .union([z.boolean(), z.enum(["true", "false"])]) + .optional() + .default(false) + .describe( + "Flag indicating whether the gist is public. Can be boolean or a string ('true' or 'false')." + ), +}); + +type CreateGistInput = z.infer; + +// Function to create a gist using GitHub's API +async function createGistFunction( + input: CreateGistInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const headers: Record = { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${metadata.auth.apiKey}`, + }; + + const response = await ofetch("https://api.github.com/gists", { + method: "POST", + body: input, + headers, + // Return the raw response text + parseResponse: (text) => text, + }); + + return ok(response); + } catch (error) { + return err( + error instanceof Error ? error : new Error("Failed to create gist") + ); + } +} + +// Exporting the tool object according to the Toolsette framework guidelines +export const createGist: ToolsetteTool = { + name: "create_gist", + description: + 'Allows you to add a new gist with one or more files.\n\n> [!NOTE]\n> Don\'t name your files "gistfile" with a numerical suffix. This is the format of the automatic naming scheme that Gist uses internally.', + parameters: createGistSchema, + function: createGistFunction, +}; diff --git a/packages/github/tools/gists-delete.test.ts b/packages/github/tools/gists-delete.test.ts new file mode 100644 index 0000000..753c2c1 --- /dev/null +++ b/packages/github/tools/gists-delete.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { ofetch } from "ofetch"; +import { deleteGist } from "./gists-delete"; + +const deleteGistFunction = deleteGist.function; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +const auth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + +describe("deleteGistFunction Integration Tests", () => { + it("should successfully delete an existing gist", async () => { + const createUrl = "https://api.github.com/gists"; + const createHeaders = { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${GITHUB_TOKEN}`, + }; + const createBody = { + description: "Test gist for successful deletion", + public: false, + files: { "test.txt": { content: "Integration test content" } }, + }; + const createdGist = await ofetch(createUrl, { + method: "POST", + headers: createHeaders, + body: createBody, + }); + const gistId = createdGist.id; + const result = await deleteGistFunction({ gist_id: gistId }, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle deletion of an already deleted gist", async () => { + const createUrl = "https://api.github.com/gists"; + const createHeaders = { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${GITHUB_TOKEN}`, + }; + const createBody = { + description: "Test gist for double deletion", + public: false, + files: { "test.txt": { content: "Delete twice" } }, + }; + const createdGist = await ofetch(createUrl, { + method: "POST", + headers: createHeaders, + body: createBody, + }); + const gistId = createdGist.id; + await deleteGistFunction({ gist_id: gistId }, { auth }); + const result = await deleteGistFunction({ gist_id: gistId }, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle deletion of a non-existent gist", async () => { + const result = await deleteGistFunction({ gist_id: "non-existent-gist-id" }, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const createUrl = "https://api.github.com/gists"; + const createHeaders = { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${GITHUB_TOKEN}`, + }; + const createBody = { + description: "Test gist for deletion with invalid auth", + public: false, + files: { "test.txt": { content: "Invalid auth test" } }, + }; + const createdGist = await ofetch(createUrl, { + method: "POST", + headers: createHeaders, + body: createBody, + }); + const gistId = createdGist.id; + const result = await deleteGistFunction( + { gist_id: gistId }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle empty gist_id", async () => { + const result = await deleteGistFunction({ gist_id: "" }, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle gist_id with only whitespace", async () => { + const result = await deleteGistFunction({ gist_id: " " }, { auth }); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/gists-delete.ts b/packages/github/tools/gists-delete.ts new file mode 100644 index 0000000..e102df5 --- /dev/null +++ b/packages/github/tools/gists-delete.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const deleteGistSchema = z.object({ + gist_id: z.string().describe("The unique identifier of the gist.") +}); + +type DeleteGistInput = z.infer; + +async function deleteGistFunction( + input: DeleteGistInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/gists/${input.gist_id}`; + + const headers = { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${metadata.auth.apiKey}`, + }; + + await ofetch(url, { + method: "DELETE", + headers, + }); + + return ok("Gist deleted successfully"); + } catch (error) { + return err( + error instanceof Error ? error : new Error("Failed to delete gist") + ); + } +} + +export const deleteGist: ToolsetteTool = { + name: "delete_gist", + description: "Delete a gist", + parameters: deleteGistSchema, + function: deleteGistFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/gists-get.test.ts b/packages/github/tools/gists-get.test.ts new file mode 100644 index 0000000..112a382 --- /dev/null +++ b/packages/github/tools/gists-get.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { getGist } from "./gists-get"; + +const getGistFunction = getGist.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +describe("getGistFunction Integration Tests", () => { + it("should fetch gist successfully with valid token and valid gist id", async () => { + const result = await getGistFunction( + { gist_id: "aa5a315d61ae9438b18d" }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid authentication token", async () => { + const result = await getGistFunction( + { gist_id: "aa5a315d61ae9438b18d" }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle non-existent gist id", async () => { + const result = await getGistFunction( + { gist_id: "nonexistentgistid" }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle empty gist id", async () => { + const result = await getGistFunction( + { gist_id: "" }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle extremely long gist id", async () => { + const longGistId = "a".repeat(1000); + const result = await getGistFunction( + { gist_id: longGistId }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch gist successfully without auth token", async () => { + const result = await getGistFunction( + { gist_id: "aa5a315d61ae9438b18d" }, + { auth: { type: "Bearer", apiKey: "" } } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/gists-get.ts b/packages/github/tools/gists-get.ts new file mode 100644 index 0000000..8ac7501 --- /dev/null +++ b/packages/github/tools/gists-get.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +/** + * Schema for the input parameters required to get a gist. + */ +const getGistSchema = z.object({ + gist_id: z.string().describe("The unique identifier of the gist."), +}); + +type GetGistInput = z.infer; + +/** + * Gets a specified gist. + * + * This endpoint supports the following custom media types: + * - application/vnd.github.raw+json: Returns the raw markdown. + * (This is the default if you do not pass any specific media type.) + * - application/vnd.github.base64+json: Returns the base64-encoded contents. + * This can be useful if your gist contains any invalid UTF-8 sequences. + * + * @param input - The input containing the gist ID. + * @param metadata - Metadata including authentication information. + * @returns A Result which contains the gist response as a string, or an Error. + */ +async function getGistFunction( + input: GetGistInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/gists/${input.gist_id}`; + + // Setup headers including the Accept header for custom media types. + const headers: Record = { + "Accept": "application/vnd.github.raw+json", + }; + + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + const response = await ofetch(url, { + method: "GET", + headers, + parseResponse: (txt) => txt, + }); + + return ok(response); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to fetch gist")); + } +} + +export const getGist: ToolsetteTool = { + name: "get_gist", + description: + "Gets a specified gist. This endpoint supports the following custom media types: application/vnd.github.raw+json returns the raw markdown (default), and application/vnd.github.base64+json returns the base64-encoded contents.", + parameters: getGistSchema, + function: getGistFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/gists-list.test.ts b/packages/github/tools/gists-list.test.ts new file mode 100644 index 0000000..ff7e961 --- /dev/null +++ b/packages/github/tools/gists-list.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { listGists } from "./gists-list"; + +const listGistsFunction = listGists.function; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +describe("listGistsFunction Integration Tests", () => { + const validAuth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + + it("should fetch gists with default parameters", async () => { + const result = await listGistsFunction({ per_page: 30, page: 1 }, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should respect per_page parameter with per_page = 5", async () => { + const result = await listGistsFunction({ per_page: 5, page: 1 }, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch gists with a valid 'since' parameter", async () => { + const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const result = await listGistsFunction({ since, per_page: 30, page: 1 }, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const result = await listGistsFunction({ per_page: 30, page: 1 }, { auth: { type: "Bearer", apiKey: "invalid-token" } }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch gists anonymously", async () => { + const result = await listGistsFunction({ per_page: 30, page: 1 }, { auth: { type: "Bearer", apiKey: "" } }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle edge case: per_page = 1 and page = 1", async () => { + const result = await listGistsFunction({ per_page: 1, page: 1 }, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle edge case: per_page = 100 and page = 1", async () => { + const result = await listGistsFunction({ per_page: 100, page: 1 }, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/listGists.ts b/packages/github/tools/gists-list.ts similarity index 59% rename from packages/github/tools/listGists.ts rename to packages/github/tools/gists-list.ts index 9119157..2973da0 100644 --- a/packages/github/tools/listGists.ts +++ b/packages/github/tools/gists-list.ts @@ -1,16 +1,15 @@ import { z } from "zod"; import { ofetch } from "ofetch"; import { Result, ok, err } from "neverthrow"; -import type { ToolsetteTool } from "@toolsette/utils"; +import { ToolsetteTool } from "@toolsette/utils"; -// Zod schema for the input parameters const listGistsSchema = z.object({ since: z .string() .datetime() .optional() .describe( - "Only show results that were last updated after the given time. Format: YYYY-MM-DDTHH:MM:SSZ" + "Only show results that were last updated after the given time. This is a timestamp in ISO 8601 format: `YYYY-MM-DDTHH:MM:SSZ`." ), per_page: z .number() @@ -18,24 +17,27 @@ const listGistsSchema = z.object({ .min(1) .max(100) .default(30) - .describe("The number of results per page (max 100)"), + .describe( + 'The number of results per page (max 100). For more information, see "[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api)."' + ), page: z .number() .int() .positive() .default(1) - .describe("The page number of the results to fetch"), + .describe( + 'The page number of the results to fetch. For more information, see "[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api)."' + ), }); type ListGistsInput = z.infer; -// Function to list gists -async function listGistsFunction( +export async function listGistsFunction( input: ListGistsInput, metadata: { auth: { type: "Bearer"; apiKey: string } } ): Promise> { try { - const query: ListGistsInput = { + const query: Record = { per_page: input.per_page, page: input.page, }; @@ -44,19 +46,19 @@ async function listGistsFunction( query["since"] = input.since; } - const headers = [["Accept", "application/vnd.github.v3+json"]]; + const headers: Record = { + Accept: "application/vnd.github.v3+json", + }; if (metadata.auth.apiKey) { - headers.push(["Authorization", `Bearer ${metadata.auth.apiKey}`]); + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; } const res = await ofetch("https://api.github.com/gists", { method: "GET", query, - headers: { - Authorization: `Bearer ${metadata.auth.apiKey}`, - }, - parseResponse: (txt) => txt, + headers, + parseResponse: (txt: string) => txt, }); return ok(res); @@ -67,7 +69,6 @@ async function listGistsFunction( } } -// Export the tool export const listGists: ToolsetteTool = { name: "list_gists", description: diff --git a/packages/github/tools/gists-update.test.ts b/packages/github/tools/gists-update.test.ts new file mode 100644 index 0000000..72a76a8 --- /dev/null +++ b/packages/github/tools/gists-update.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { updateGist } from "./gists-update"; + +const updateGistFunction = updateGist.function; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GIST_ID = process.env.GIST_ID; +const GIST_FILE = process.env.GIST_FILE; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} +if (!GIST_ID) { + throw new Error("GIST_ID environment variable is required to run tests"); +} + +describe("updateGistFunction Integration Tests", () => { + it("should update the gist description only", async () => { + const input = { + gist_id: GIST_ID, + description: "Updated description " + new Date().toISOString() + }; + const auth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + const result = await updateGistFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + if (GIST_FILE) { + it("should update the file content only", async () => { + const input = { + gist_id: GIST_ID, + files: { + [GIST_FILE]: { + content: "Updated file content " + new Date().toISOString() + } + } + }; + const auth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + const result = await updateGistFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should rename a file and then revert the filename", async () => { + const newFilename = "renamed-" + GIST_FILE; + const auth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + const renameInput = { + gist_id: GIST_ID, + files: { + [GIST_FILE]: { + filename: newFilename, + content: "Renamed file content " + new Date().toISOString() + } + } + }; + const renameResult = await updateGistFunction(renameInput, { auth }); + expect(renameResult).toMatchInlineSnapshot(); + const revertInput = { + gist_id: GIST_ID, + files: { + [newFilename]: { + filename: GIST_FILE, + content: "Reverted file content " + new Date().toISOString() + } + } + }; + const revertResult = await updateGistFunction(revertInput, { auth }); + expect(revertResult).toMatchInlineSnapshot(); + }); + + it("should add a new file then delete it", async () => { + const newFileName = "tempFile.txt"; + const auth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + const addInput = { + gist_id: GIST_ID, + files: { + [newFileName]: { + content: "Temporary file content " + new Date().toISOString() + } + } + }; + const addResult = await updateGistFunction(addInput, { auth }); + expect(addResult).toMatchInlineSnapshot(); + const deleteInput = { + gist_id: GIST_ID, + files: { + [newFileName]: null + } + }; + const deleteResult = await updateGistFunction(deleteInput, { auth }); + expect(deleteResult).toMatchInlineSnapshot(); + }); + } + + it("should update both description and file content", async () => { + const auth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + const input: any = { + gist_id: GIST_ID, + description: "Updated description and file " + new Date().toISOString() + }; + if (GIST_FILE) { + input.files = { + [GIST_FILE]: { + content: "Updated both file content " + new Date().toISOString() + } + }; + } + const result = await updateGistFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail update with invalid auth token", async () => { + const input = { + gist_id: GIST_ID, + description: "This update should fail due to invalid token" + }; + const invalidAuth = { type: "Bearer", apiKey: "invalid-token" }; + const result = await updateGistFunction(input, { auth: invalidAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail update with non-existent gist_id", async () => { + const input = { + gist_id: "non-existent-gist-id-1234", + description: "Trying to update a non-existent gist" + }; + const auth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + const result = await updateGistFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle empty update input", async () => { + const input = { + gist_id: GIST_ID + }; + const auth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + const result = await updateGistFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/gists-update.ts b/packages/github/tools/gists-update.ts new file mode 100644 index 0000000..5582266 --- /dev/null +++ b/packages/github/tools/gists-update.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// Define the schema for the input parameters. +// It includes the path parameter `gist_id` and the request body properties. +// Note: At least one of `description` or `files` is required, but we keep the schema simple. +const updateGistSchema = z.object({ + // The unique identifier of the gist (path parameter). + gist_id: z.string().describe("The unique identifier of the gist."), + + // The description of the gist. + // Example: "Example Ruby script". + description: z.string().optional().describe("The description of the gist. Example: 'Example Ruby script'"), + + // The gist files to be updated, renamed, or deleted. + // Each key must match the current filename of the targeted gist file. + // To delete a file, set the file to null. + files: z + .record( + z.union([ + z + .object({ + content: z.string().optional().describe("The new content of the file."), + filename: z + .string() + .nullable() + .optional() + .describe("The new filename for the file.") + }) + .describe("File update object"), + z.null() + ]) + ) + .optional() + .describe( + "The gist files to be updated, renamed, or deleted. Each key must match the current filename (including extension) of the targeted gist file. To delete a file, set the file to null." + ) +}); + +type UpdateGistInput = z.infer; + +/** + * Updates a gist's description and/or files. + * Supports updating the description, renaming files, or deleting files. + * + * @param input - Input parameters including `gist_id`, and optionally `description` and/or `files` + * @param metadata - Metadata containing authentication details. + * + * @returns A Result containing the response on success, or an Error on failure. + */ +async function updateGistFunction( + input: UpdateGistInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + // Build the URL by injecting the gist_id. + const url = `https://api.github.com/gists/${input.gist_id}`; + + // Construct the request payload. + // Only include description and files if they are provided. + const payload: Record = {}; + if (input.description !== undefined) { + payload.description = input.description; + } + if (input.files !== undefined) { + payload.files = input.files; + } + + // Set up the HTTP headers. + const headers: Record = { + Accept: "application/vnd.github.raw+json", + "Content-Type": "application/json" + }; + + // Include authorization header if API key is provided. + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + // Make the PATCH request using ofetch. + const res = await ofetch(url, { + method: "PATCH", + headers, + body: payload + }); + + return ok(res); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to update gist")); + } +} + +export const updateGist: ToolsetteTool = { + name: "update_gist", + description: + "Allows you to update a gist's description and to update, delete, or rename gist files. Files from the previous version of the gist that aren't explicitly changed during an edit are unchanged.", + parameters: updateGistSchema, + function: updateGistFunction +}; \ No newline at end of file diff --git a/packages/github/tools/issues-add-assignees.test.ts b/packages/github/tools/issues-add-assignees.test.ts new file mode 100644 index 0000000..2f37427 --- /dev/null +++ b/packages/github/tools/issues-add-assignees.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { addAssignees } from "./gists-add_assignees"; + +const addAssigneesFunction = addAssignees.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const REPO_OWNER = process.env.GITHUB_REPO_OWNER; +const REPO_NAME = process.env.GITHUB_REPO_NAME; +const ISSUE_NUMBER = process.env.GITHUB_ISSUE_NUMBER; +const VALID_ASSIGNEE = process.env.GITHUB_ASSIGNEE || REPO_OWNER; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} +if (!REPO_OWNER) { + throw new Error("GITHUB_REPO_OWNER environment variable is required to run tests"); +} +if (!REPO_NAME) { + throw new Error("GITHUB_REPO_NAME environment variable is required to run tests"); +} +if (!ISSUE_NUMBER) { + throw new Error("GITHUB_ISSUE_NUMBER environment variable is required to run tests"); +} + +describe("addAssigneesFunction Integration Tests", () => { + const auth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + + it("should add a valid single assignee", async () => { + const result = await addAssigneesFunction( + { + owner: REPO_OWNER, + repo: REPO_NAME, + issue_number: Number(ISSUE_NUMBER), + assignees: [VALID_ASSIGNEE], + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should succeed when no assignees provided", async () => { + const result = await addAssigneesFunction( + { + owner: REPO_OWNER, + repo: REPO_NAME, + issue_number: Number(ISSUE_NUMBER), + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail with invalid authentication token", async () => { + const result = await addAssigneesFunction( + { + owner: REPO_OWNER, + repo: REPO_NAME, + issue_number: Number(ISSUE_NUMBER), + assignees: [VALID_ASSIGNEE], + }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail when providing too many assignees", async () => { + const manyAssignees = Array.from({ length: 11 }, (_, i) => `nonexistentuser${i + 1}`); + const result = await addAssigneesFunction( + { + owner: REPO_OWNER, + repo: REPO_NAME, + issue_number: Number(ISSUE_NUMBER), + assignees: manyAssignees, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail when issue does not exist", async () => { + const result = await addAssigneesFunction( + { + owner: REPO_OWNER, + repo: REPO_NAME, + issue_number: 9999999, + assignees: [VALID_ASSIGNEE], + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail when negative issue number is provided", async () => { + const result = await addAssigneesFunction( + { + owner: REPO_OWNER, + repo: REPO_NAME, + issue_number: -1, + assignees: [VALID_ASSIGNEE], + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle multiple valid assignees (duplicates)", async () => { + const result = await addAssigneesFunction( + { + owner: REPO_OWNER, + repo: REPO_NAME, + issue_number: Number(ISSUE_NUMBER), + assignees: [VALID_ASSIGNEE, VALID_ASSIGNEE], + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-add-assignees.ts b/packages/github/tools/issues-add-assignees.ts new file mode 100644 index 0000000..cf4b5eb --- /dev/null +++ b/packages/github/tools/issues-add-assignees.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const addAssigneesSchema = z.object({ + owner: z.string().describe("The account owner of the repository. The name is not case sensitive."), + repo: z.string().describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + issue_number: z.number().int().describe("The number that identifies the issue."), + assignees: z + .array(z.string()) + .max(10, "You can assign up to 10 users") + .optional() + .describe( + "Usernames of people to assign this issue to. _NOTE: Only users with push access can add assignees to an issue. Assignees are silently ignored otherwise._" + ) +}); + +type AddAssigneesInput = z.infer; + +async function addAssigneesFunction( + input: AddAssigneesInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + // Construct the API URL using path parameters + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/issues/${input.issue_number}/assignees`; + + const headers: Record = { + "Accept": "application/vnd.github+json", + "Authorization": `Bearer ${metadata.auth.apiKey}` + }; + + // If a list of assignees is provided, include it in the request body. + // The request body is optional. + const bodyPayload = input.assignees ? { assignees: input.assignees } : {}; + + const res = await ofetch(url, { + method: "POST", + headers, + body: JSON.stringify(bodyPayload), + // Convert the response to text + parseResponse: (txt) => txt + }); + + return ok(res); + } catch (error) { + return err( + error instanceof Error ? error : new Error("Failed to add assignees to the issue") + ); + } +} + +export const addAssignees: ToolsetteTool = { + name: "add_assignees", + description: + "Adds up to 10 assignees to an issue. Users already assigned to an issue are not replaced.", + parameters: addAssigneesSchema, + function: addAssigneesFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-add-labels.test.ts b/packages/github/tools/issues-add-labels.test.ts new file mode 100644 index 0000000..4370cc1 --- /dev/null +++ b/packages/github/tools/issues-add-labels.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { addLabelsToIssue } from "./issues-add-labels"; + +const addLabelsFunction = addLabelsToIssue.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_TEST_OWNER = process.env.GITHUB_TEST_OWNER; +const GITHUB_TEST_REPO = process.env.GITHUB_TEST_REPO; +const GITHUB_TEST_ISSUE_NUMBER = process.env.GITHUB_TEST_ISSUE_NUMBER; + +if ( + !GITHUB_TOKEN || + !GITHUB_TEST_OWNER || + !GITHUB_TEST_REPO || + !GITHUB_TEST_ISSUE_NUMBER +) { + throw new Error( + "GITHUB_TOKEN, GITHUB_TEST_OWNER, GITHUB_TEST_REPO, and GITHUB_TEST_ISSUE_NUMBER environment variables are required to run tests" + ); +} + +describe("addLabelsToIssue Integration Tests", () => { + const auth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + const owner = GITHUB_TEST_OWNER; + const repo = GITHUB_TEST_REPO; + const issue_number = parseInt(GITHUB_TEST_ISSUE_NUMBER, 10); + + it("should add labels using object with labels key (array of strings)", async () => { + const result = await addLabelsFunction( + { owner, repo, issue_number, body: { labels: ["bug", "enhancement"] } }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should add labels using array of strings", async () => { + const result = await addLabelsFunction( + { owner, repo, issue_number, body: ["documentation", "question"] }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should add labels using object with labels key (array of objects)", async () => { + const result = await addLabelsFunction( + { owner, repo, issue_number, body: { labels: [{ name: "wontfix" }] } }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should add labels using array of objects", async () => { + const result = await addLabelsFunction( + { owner, repo, issue_number, body: [{ name: "duplicate" }] }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail when using a single label as string", async () => { + const result = await addLabelsFunction( + { owner, repo, issue_number, body: "help wanted" }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const result = await addLabelsFunction( + { owner, repo, issue_number, body: ["bug"] }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail when the issue does not exist", async () => { + const result = await addLabelsFunction( + { owner, repo, issue_number: 9999999, body: ["bug"] }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail when no body is provided", async () => { + const result = await addLabelsFunction( + { owner, repo, issue_number }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail when issue_number is negative", async () => { + const result = await addLabelsFunction( + { owner, repo, issue_number: -1, body: ["bug"] }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-add-labels.ts b/packages/github/tools/issues-add-labels.ts new file mode 100644 index 0000000..0dd3bd2 --- /dev/null +++ b/packages/github/tools/issues-add-labels.ts @@ -0,0 +1,144 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// --- Request Body Schemas --- + +// Alternative 1: An object with a "labels" key whose value is an array of strings. +const labelStringListObjectSchema = z + .object({ + labels: z + .array(z.string()) + .min(1, "Must have at least 1 label") + .describe( + "The names of the labels to add to the issue's existing labels. You can pass an empty array to remove all labels. Alternatively, you can pass a single label as a string or an array of labels directly, but GitHub recommends passing an object with the labels key." + ), + }) + .describe("Object with a labels key that is an array of strings"); + +// Alternative 2: An array of strings. +const labelStringArraySchema = z + .array(z.string()) + .min(1, "Must have at least 1 label") + .describe("An array of strings representing label names"); + +// Alternative 3: An object with a "labels" key whose value is an array of objects (each with a name property). +const labelObjectListObjectSchema = z + .object({ + labels: z + .array( + z + .object({ + name: z.string().describe("The name of the label."), + }) + .describe("Label object") + ) + .min(1, "Must have at least 1 label"), + }) + .describe("Object with a labels key that is an array of label objects"); + +// Alternative 4: An array of objects (each with a name property). +const labelObjectArraySchema = z + .array( + z.object({ + name: z.string().describe("The name of the label."), + }) + ) + .min(1, "Must have at least 1 label") + .describe("An array of label objects"); + +// Alternative 5: A single label given as a string. +const labelStringSchema = z.string().describe("A single label as a string"); + +// Union of all request body alternatives. +const requestBodySchema = z + .union([ + labelStringListObjectSchema, + labelStringArraySchema, + labelObjectListObjectSchema, + labelObjectArraySchema, + labelStringSchema, + ]) + .describe("Request body for adding labels to an issue"); + +// --- Combined Input Schema --- + +const addLabelsInputSchema = z.object({ + // Path parameter: owner of repository. + owner: z + .string() + .describe( + "The account owner of the repository. The name is not case sensitive." + ), + // Path parameter: repository name. + repo: z + .string() + .describe( + "The name of the repository without the `.git` extension. The name is not case sensitive." + ), + // Path parameter: the issue number. + issue_number: z + .number() + .int() + .describe("The number that identifies the issue."), + // Request body is optional. + body: requestBodySchema.optional(), +}); + +export type AddLabelsInput = z.infer; + +// --- Tool Implementation Function --- + +async function addLabelsFunction( + input: AddLabelsInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + // Construct the endpoint URL using the provided path parameters. + const url = `https://api.github.com/repos/${encodeURIComponent( + input.owner + )}/${encodeURIComponent(input.repo)}/issues/${input.issue_number}/labels`; + + // Prepare the request headers. + const headers: Record = { + Accept: "application/vnd.github.v3+json", + "Content-Type": "application/json", + }; + + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + // Prepare the request body if provided. + // Note: The GitHub API accepts multiple formats; we pass the input.body as is. + const body = input.body !== undefined ? JSON.stringify(input.body) : undefined; + + // Send the HTTP POST request using ofetch. + const res = await ofetch(url, { + method: "POST", + headers, + body, + // We choose a simple parse that returns the raw response text. + parseResponse: (responseText) => responseText, + }); + + return ok(res); + } catch (error) { + return err( + error instanceof Error + ? error + : new Error("Failed to add labels to the issue") + ); + } +} + +// --- Export the Tool Object --- + +export const addLabelsToIssue: ToolsetteTool = { + name: "add_labels_to_issue", + description: + "Adds labels to an issue. If you provide an empty array of labels, all labels are removed from the issue.", + parameters: addLabelsInputSchema, + function: addLabelsFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-create-comment.test.ts b/packages/github/tools/issues-create-comment.test.ts new file mode 100644 index 0000000..7e15b39 --- /dev/null +++ b/packages/github/tools/issues-create-comment.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { createIssueComment } from "./create_issue_comment"; + +const createIssueCommentFunction = createIssueComment.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_TEST_OWNER = process.env.GITHUB_TEST_OWNER; +const GITHUB_TEST_REPO = process.env.GITHUB_TEST_REPO; +const GITHUB_TEST_ISSUE_NUMBER = process.env.GITHUB_TEST_ISSUE_NUMBER; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} +if (!GITHUB_TEST_OWNER) { + throw new Error("GITHUB_TEST_OWNER environment variable is required to run tests"); +} +if (!GITHUB_TEST_REPO) { + throw new Error("GITHUB_TEST_REPO environment variable is required to run tests"); +} +if (!GITHUB_TEST_ISSUE_NUMBER || isNaN(Number(GITHUB_TEST_ISSUE_NUMBER))) { + throw new Error("GITHUB_TEST_ISSUE_NUMBER environment variable is required and must be a number"); +} + +describe("createIssueCommentFunction Integration Tests", () => { + const validAuth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + const validInput = { + owner: GITHUB_TEST_OWNER, + repo: GITHUB_TEST_REPO, + issue_number: Number(GITHUB_TEST_ISSUE_NUMBER), + body: "Integration Test Comment", + }; + + it("should create an issue comment with valid parameters", async () => { + const result = await createIssueCommentFunction(validInput, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should create an issue comment with a different body text", async () => { + const input = { ...validInput, body: "Another integration test comment." }; + const result = await createIssueCommentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid authentication token", async () => { + const invalidAuth = { type: "Bearer" as const, apiKey: "invalid-token" }; + const result = await createIssueCommentFunction(validInput, { auth: invalidAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail to create comment with empty body", async () => { + const input = { ...validInput, body: "" }; + const result = await createIssueCommentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail to create comment for non-existent issue number", async () => { + const input = { ...validInput, issue_number: 9999999, body: "Test comment for non-existent issue" }; + const result = await createIssueCommentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail to create comment with a negative issue number", async () => { + const input = { ...validInput, issue_number: -1, body: "Test comment with negative issue number" }; + const result = await createIssueCommentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should create an issue comment with a long body", async () => { + const input = { ...validInput, body: "A".repeat(1000) }; + const result = await createIssueCommentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-create-comment.ts b/packages/github/tools/issues-create-comment.ts new file mode 100644 index 0000000..ffd8c13 --- /dev/null +++ b/packages/github/tools/issues-create-comment.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const createIssueCommentSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + issue_number: z + .number() + .int() + .describe("The number that identifies the issue."), + body: z + .string() + .describe("The contents of the comment. Example: 'Me too'") +}); + +type CreateIssueCommentInput = z.infer; + +async function createIssueCommentFunction( + input: CreateIssueCommentInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/issues/${input.issue_number}/comments`; + + const headers: Record = { + "Accept": "application/vnd.github.v3+json", + "Content-Type": "application/json" + }; + + if (metadata.auth.apiKey) { + headers.Authorization = `Bearer ${metadata.auth.apiKey}`; + } + + const response = await ofetch(url, { + method: "POST", + headers, + body: { body: input.body }, + // We use a simple parser to return the raw response text. + parseResponse: (text: string) => text + }); + + return ok(response); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to create issue comment")); + } +} + +export const createIssueComment: ToolsetteTool = { + name: "create_issue_comment", + description: + "Create an issue comment. You can use the REST API to create comments on issues and pull requests. Every pull request is an issue, but not every issue is a pull request. This endpoint triggers notifications and creating content too quickly may result in secondary rate limiting. It supports multiple custom media types: application/vnd.github.raw+json (default), application/vnd.github.text+json, application/vnd.github.html+json, and application/vnd.github.full+json.", + parameters: createIssueCommentSchema, + function: createIssueCommentFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-create-label.test.ts b/packages/github/tools/issues-create-label.test.ts new file mode 100644 index 0000000..2e177d4 --- /dev/null +++ b/packages/github/tools/issues-create-label.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { createLabel } from "./gists-create-label"; + +const createLabelFunction = createLabel.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_TEST_OWNER = process.env.GITHUB_TEST_OWNER; +const GITHUB_TEST_REPO = process.env.GITHUB_TEST_REPO; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} +if (!GITHUB_TEST_OWNER) { + throw new Error("GITHUB_TEST_OWNER environment variable is required to run tests"); +} +if (!GITHUB_TEST_REPO) { + throw new Error("GITHUB_TEST_REPO environment variable is required to run tests"); +} + +describe("createLabelFunction Integration Tests", () => { + const auth = { + type: "Bearer" as const, + apiKey: GITHUB_TOKEN, + }; + + it("should successfully create a label with valid parameters", async () => { + const uniqueLabelName = `integration-test-label-${Date.now()}`; + const input = { + owner: GITHUB_TEST_OWNER, + repo: GITHUB_TEST_REPO, + name: uniqueLabelName, + color: "BADA55", + }; + const result = await createLabelFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should successfully create a label with a description provided", async () => { + const uniqueLabelName = `integration-test-label-desc-${Date.now()}`; + const input = { + owner: GITHUB_TEST_OWNER, + repo: GITHUB_TEST_REPO, + name: uniqueLabelName, + color: "C0FFEE", + description: "This is a test label with a description", + }; + const result = await createLabelFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail with invalid auth token", async () => { + const uniqueLabelName = `integration-test-label-invalid-auth-${Date.now()}`; + const input = { + owner: GITHUB_TEST_OWNER, + repo: GITHUB_TEST_REPO, + name: uniqueLabelName, + color: "00FF00", + }; + const result = await createLabelFunction(input, { + auth: { type: "Bearer", apiKey: "invalid-token" }, + }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle non-existent repository error", async () => { + const uniqueRepoName = `non-existent-repo-${Date.now()}`; + const uniqueLabelName = `integration-test-label-nonexistent-repo-${Date.now()}`; + const input = { + owner: GITHUB_TEST_OWNER, + repo: uniqueRepoName, + name: uniqueLabelName, + color: "FFAA00", + }; + const result = await createLabelFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle duplicate label creation error", async () => { + const uniqueLabelName = `integration-test-label-duplicate-${Date.now()}`; + const input = { + owner: GITHUB_TEST_OWNER, + repo: GITHUB_TEST_REPO, + name: uniqueLabelName, + color: "FF5733", + }; + // First creation + await createLabelFunction(input, { auth }); + // Duplicate creation should result in an error + const secondResult = await createLabelFunction(input, { auth }); + expect(secondResult).toMatchInlineSnapshot(); + }); + + it("should successfully create a label with maximum length description (100 characters)", async () => { + const uniqueLabelName = `integration-test-label-max-desc-${Date.now()}`; + const maxDescription = "a".repeat(100); + const input = { + owner: GITHUB_TEST_OWNER, + repo: GITHUB_TEST_REPO, + name: uniqueLabelName, + color: "123ABC", + description: maxDescription, + }; + const result = await createLabelFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail with invalid hexadecimal color code", async () => { + const uniqueLabelName = `integration-test-label-invalid-color-${Date.now()}`; + const input = { + owner: GITHUB_TEST_OWNER, + repo: GITHUB_TEST_REPO, + name: uniqueLabelName, + color: "ZZZZZZ", + }; + const result = await createLabelFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-create-label.ts b/packages/github/tools/issues-create-label.ts new file mode 100644 index 0000000..a0b5aca --- /dev/null +++ b/packages/github/tools/issues-create-label.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// +// Create a zod schema that combines all input parameters +// Both the path parameters (owner, repo) and the request body parameters (name, color, description) +// +const createLabelSchema = z.object({ + // Path parameters + owner: z.string().describe("The account owner of the repository. The name is not case sensitive."), + repo: z.string().describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + // Request body parameters + name: z.string().describe( + "The name of the label. Emoji can be added to label names, using either native emoji or colon-style markup. For example, typing `:strawberry:` will render the emoji. For a full list of available emoji and codes, see 'Emoji cheat sheet'." + ), + // Although the OpenAPI description says the color parameter is required, the schema in the requestBody lists only 'name' as required. + // Here we make color required because the operation description notes that both name and color are required. + color: z + .string() + .regex(/^[0-9A-Fa-f]{6}$/, "Invalid hexadecimal color code") + .describe("The hexadecimal color code for the label, without the leading `#`."), + description: z + .string() + .max(100, "Must be 100 characters or fewer") + .optional() + .describe("A short description of the label. Must be 100 characters or fewer."), +}); + +type CreateLabelInput = z.infer; + +// +// The tool function that calls GitHub's API to create a label +// +async function createLabelFunction( + input: CreateLabelInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + // Construct the URL by interpolating the path parameters (owner and repo) + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/labels`; + + // Build the request body using the provided input. + // Include the optional 'description' only if it is provided. + const bodyData: { name: string; color: string; description?: string } = { + name: input.name, + color: input.color, + }; + if (input.description !== undefined) { + bodyData.description = input.description; + } + + // Setup headers: authenticate with a Bearer token and specify Accept header. + const headers = { + Authorization: `Bearer ${metadata.auth.apiKey}`, + Accept: "application/vnd.github+json", + }; + + // Make a POST request to create the label. + // The parseResponse option is set to return the raw response text. + const res = await ofetch(url, { + method: "POST", + body: bodyData, + headers, + parseResponse: (responseText: string) => responseText, + }); + + return ok(res); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to create label")); + } +} + +// +// Export the tool object with the required properties +// +export const createLabel: ToolsetteTool = { + name: "create_label", + description: "Creates a label for the specified repository with the given name and color.", + parameters: createLabelSchema, + function: createLabelFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-create.test.ts b/packages/github/tools/issues-create.test.ts new file mode 100644 index 0000000..1d568d5 --- /dev/null +++ b/packages/github/tools/issues-create.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { createIssue } from "./gists-create"; + +const createIssueFunction = createIssue.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_OWNER = process.env.GITHUB_OWNER; +const GITHUB_REPO = process.env.GITHUB_REPO; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +if (!GITHUB_OWNER) { + throw new Error("GITHUB_OWNER environment variable is required to run tests"); +} + +if (!GITHUB_REPO) { + throw new Error("GITHUB_REPO environment variable is required to run tests"); +} + +describe("createIssueFunction Integration Tests", () => { + const validAuth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + + it("should create an issue with minimal parameters", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + title: `Test Minimal Issue ${Date.now()}`, + }; + const result = await createIssueFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should create an issue with full parameters", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + title: `Test Full Issue ${Date.now()}`, + body: "This is a test issue with full parameters.", + assignee: GITHUB_OWNER, + milestone: 1, + labels: ["bug", "enhancement"], + assignees: [GITHUB_OWNER], + }; + const result = await createIssueFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should create an issue with numeric title", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + title: 12345, + body: "Issue with numeric title", + }; + const result = await createIssueFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + title: `Test Invalid Auth ${Date.now()}`, + }; + const invalidAuth = { type: "Bearer" as const, apiKey: "invalid-token" }; + const result = await createIssueFunction(input, { auth: invalidAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail for non-existing repository", async () => { + const input = { + owner: GITHUB_OWNER, + repo: "non-existent-repo-for-testing", + title: `Test Non-existent Repo ${Date.now()}`, + }; + const result = await createIssueFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-create.ts b/packages/github/tools/issues-create.ts new file mode 100644 index 0000000..c1fcd4e --- /dev/null +++ b/packages/github/tools/issues-create.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const createIssueSchema = z.object({ + // Path parameters + owner: z.string().describe("The account owner of the repository. The name is not case sensitive. (Example: 'octocat')"), + repo: z.string().describe("The name of the repository without the `.git` extension. The name is not case sensitive. (Example: 'Hello-World')"), + // Request body parameters + title: z.union([z.string(), z.number()]).describe("The title of the issue. (Example: 'Found a bug')"), + body: z.string().optional().describe("The contents of the issue. (Example: 'I'm having a problem with this.')"), + assignee: z.union([z.string(), z.null()]).optional().describe( + "Login for the user that this issue should be assigned to. _NOTE: Only users with push access can set the assignee for new issues. The assignee is silently dropped otherwise. **This field is closing down.**_" + ), + milestone: z.union([z.string(), z.number(), z.null()]).optional().describe( + "The milestone to associate this issue with. (Example: 1)" + ), + labels: z + .array( + z.union([ + z.string(), + z.object({ + id: z.number(), + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + color: z.union([z.string(), z.null()]).optional() + }) + ]) + ) + .optional() + .describe("Labels to associate with this issue. (Example: ['bug'])"), + assignees: z + .array(z.string()) + .optional() + .describe("Logins for Users to assign to this issue. (Example: ['octocat'])") +}); + +type CreateIssueInput = z.infer; + +async function createIssueFunction( + input: CreateIssueInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + // Extract the path parameters and use the rest as the request body. + const { owner, repo, ...issueData } = input; + const url = `https://api.github.com/repos/${owner}/${repo}/issues`; + + const response = await ofetch(url, { + method: "POST", + headers: { + "Authorization": `Bearer ${metadata.auth.apiKey}`, + "Accept": "application/vnd.github.v3+json", + "Content-Type": "application/json" + }, + body: JSON.stringify(issueData) + }); + + return ok(response); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to create issue")); + } +} + +export const createIssue: ToolsetteTool = { + name: "create_issue", + description: + "Create an issue. Any user with pull access to a repository can create an issue. If issues are disabled in the repository (link: https://docs.github.com/articles/disabling-issues/), the API returns a 410 Gone status. This endpoint triggers notifications and supports custom media types such as application/vnd.github.raw+json, application/vnd.github.text+json, application/vnd.github.html+json, and application/vnd.github.full+json.", + parameters: createIssueSchema, + function: createIssueFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-delete-comment.test.ts b/packages/github/tools/issues-delete-comment.test.ts new file mode 100644 index 0000000..66c7d5e --- /dev/null +++ b/packages/github/tools/issues-delete-comment.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { ofetch } from "ofetch"; +import { deleteIssueComment } from "./gists-delete_issue_comment"; + +const deleteCommentFunction = deleteIssueComment.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const TEST_REPO_OWNER = process.env.TEST_REPO_OWNER; +const TEST_REPO = process.env.TEST_REPO; +const TEST_ISSUE_NUMBER = process.env.TEST_ISSUE_NUMBER; + +if (!GITHUB_TOKEN || !TEST_REPO_OWNER || !TEST_REPO || !TEST_ISSUE_NUMBER) { + throw new Error( + "GITHUB_TOKEN, TEST_REPO_OWNER, TEST_REPO, and TEST_ISSUE_NUMBER environment variables are required to run tests" + ); +} + +async function createIssueComment(): Promise { + const url = `https://api.github.com/repos/${TEST_REPO_OWNER}/${TEST_REPO}/issues/${TEST_ISSUE_NUMBER}/comments`; + const headers = { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${GITHUB_TOKEN}`, + }; + const body = { body: "Integration test comment - will be deleted" }; + const response = await ofetch(url, { + method: "POST", + headers, + body, + }); + return response.id; +} + +describe("deleteCommentFunction Integration Tests", () => { + const validAuth = { auth: { type: "Bearer" as const, apiKey: GITHUB_TOKEN } }; + + it("should delete a comment successfully", async () => { + const commentId = await createIssueComment(); + const result = await deleteCommentFunction( + { + owner: TEST_REPO_OWNER, + repo: TEST_REPO, + comment_id: commentId, + }, + validAuth + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error for non-existent comment", async () => { + const result = await deleteCommentFunction( + { + owner: TEST_REPO_OWNER, + repo: TEST_REPO, + comment_id: 999999999999, + }, + validAuth + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const commentId = await createIssueComment(); + let result; + try { + result = await deleteCommentFunction( + { + owner: TEST_REPO_OWNER, + repo: TEST_REPO, + comment_id: commentId, + }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + } finally { + // Cleanup: ensure the comment is deleted + try { + await deleteCommentFunction( + { owner: TEST_REPO_OWNER, repo: TEST_REPO, comment_id: commentId }, + validAuth + ); + } catch (_) {} + } + }); + + it("should error on deletion with negative comment_id", async () => { + const result = await deleteCommentFunction( + { + owner: TEST_REPO_OWNER, + repo: TEST_REPO, + comment_id: -1, + }, + validAuth + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should error when repository does not exist", async () => { + const result = await deleteCommentFunction( + { + owner: TEST_REPO_OWNER, + repo: "non-existent-repo-xyz", + comment_id: 1, + }, + validAuth + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-delete-comment.ts b/packages/github/tools/issues-delete-comment.ts new file mode 100644 index 0000000..a817eff --- /dev/null +++ b/packages/github/tools/issues-delete-comment.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const deleteCommentSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + comment_id: z + .number() + .int() + .describe("The unique identifier of the comment."), +}); + +type DeleteCommentInput = z.infer; + +async function deleteCommentFunction( + input: DeleteCommentInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/issues/comments/${input.comment_id}`; + + const headers = { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${metadata.auth.apiKey}`, + }; + + await ofetch(url, { + method: "DELETE", + headers, + // Since DELETE returns a 204 No Content, we parse the response as text + parseResponse: (txt: string) => txt, + }); + + return ok("Comment deleted successfully"); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to delete comment")); + } +} + +export const deleteIssueComment: ToolsetteTool = { + name: "delete_issue_comment", + description: + "You can use the REST API to delete comments on issues and pull requests. Every pull request is an issue, but not every issue is a pull request.", + parameters: deleteCommentSchema, + function: deleteCommentFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-delete-label.test.ts b/packages/github/tools/issues-delete-label.test.ts new file mode 100644 index 0000000..fb04858 --- /dev/null +++ b/packages/github/tools/issues-delete-label.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { ofetch } from "ofetch"; +import { deleteLabel } from "./gists-delete"; + +const deleteLabelFunction = deleteLabel.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_OWNER = process.env.GITHUB_OWNER; +const GITHUB_REPO = process.env.GITHUB_REPO; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} +if (!GITHUB_OWNER) { + throw new Error("GITHUB_OWNER environment variable is required to run tests"); +} +if (!GITHUB_REPO) { + throw new Error("GITHUB_REPO environment variable is required to run tests"); +} + +async function createLabel(label: string): Promise { + const url = `https://api.github.com/repos/${encodeURIComponent( + GITHUB_OWNER + )}/${encodeURIComponent(GITHUB_REPO)}/labels`; + try { + await ofetch(url, { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${GITHUB_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: label, color: "f29513" }), + }); + } catch (error: unknown) { + if (error instanceof Error && /already exists/i.test(error.message)) return; + throw error; + } +} + +describe("deleteLabelFunction Integration Tests", () => { + it("should delete label successfully", async () => { + const label = `test-label-${Date.now()}`; + await createLabel(label); + const result = await deleteLabelFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + name: label, + }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return an error when label does not exist", async () => { + const label = `non-existent-label-${Date.now()}`; + const result = await deleteLabelFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + name: label, + }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const label = `test-label-invalid-auth-${Date.now()}`; + await createLabel(label); + const result = await deleteLabelFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + name: label, + }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + if (result.isErr()) { + await deleteLabelFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + name: label, + }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + } + }); + + it("should delete label with special characters in label name", async () => { + const label = `test/label+special-${Date.now()}`; + await createLabel(label); + const result = await deleteLabelFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + name: label, + }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return an error when repository does not exist", async () => { + const label = `test-label-repo-not-found-${Date.now()}`; + const result = await deleteLabelFunction( + { + owner: GITHUB_OWNER, + repo: `non-existent-repo-${Date.now()}`, + name: label, + }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-delete-label.ts b/packages/github/tools/issues-delete-label.ts new file mode 100644 index 0000000..3dcbc22 --- /dev/null +++ b/packages/github/tools/issues-delete-label.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const deleteLabelSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + name: z + .string() + .describe("The label name to delete."), +}); + +type DeleteLabelInput = z.infer; + +async function deleteLabelFunction( + input: DeleteLabelInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + // Construct the API endpoint URL with proper encoding + const url = `https://api.github.com/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/labels/${encodeURIComponent(input.name)}`; + + const headers: Record = { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${metadata.auth.apiKey}`, + }; + + // Perform the DELETE request using ofetch + await ofetch(url, { + method: "DELETE", + headers, + }); + + return ok("Label deleted successfully."); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to delete label")); + } +} + +export const deleteLabel: ToolsetteTool = { + name: "delete_label", + description: "Deletes a label using the given label name.", + parameters: deleteLabelSchema, + function: deleteLabelFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-get-comment.test.ts b/packages/github/tools/issues-get-comment.test.ts new file mode 100644 index 0000000..460feec --- /dev/null +++ b/packages/github/tools/issues-get-comment.test.ts @@ -0,0 +1,89 @@ +```typescript +import { describe, it, expect } from "vitest"; +import { getIssueComment } from "./get_issue_comment"; + +const getIssueCommentFunction = getIssueComment.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_OWNER = process.env.GITHUB_OWNER; +const GITHUB_REPO = process.env.GITHUB_REPO; +const GITHUB_COMMENT_ID = process.env.GITHUB_COMMENT_ID; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +if (!GITHUB_OWNER) { + throw new Error("GITHUB_OWNER environment variable is required to run tests"); +} + +if (!GITHUB_REPO) { + throw new Error("GITHUB_REPO environment variable is required to run tests"); +} + +if (!GITHUB_COMMENT_ID) { + throw new Error("GITHUB_COMMENT_ID environment variable is required to run tests"); +} + +describe("getIssueCommentFunction Integration Tests", () => { + const validAuth = { + type: "Bearer" as const, + apiKey: GITHUB_TOKEN, + }; + + it("should fetch an issue comment with valid parameters", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + comment_id: Number(GITHUB_COMMENT_ID), + }; + + const result = await getIssueCommentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle non-existent comment", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + comment_id: 9999999999, + }; + + const result = await getIssueCommentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + comment_id: Number(GITHUB_COMMENT_ID), + }; + + const result = await getIssueCommentFunction(input, { auth: { type: "Bearer", apiKey: "invalid-token" } }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle comment_id as 0 (edge case)", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + comment_id: 0, + }; + + const result = await getIssueCommentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle negative comment_id (edge case)", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + comment_id: -1, + }; + + const result = await getIssueCommentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); +}); +``` \ No newline at end of file diff --git a/packages/github/tools/issues-get-comment.ts b/packages/github/tools/issues-get-comment.ts new file mode 100644 index 0000000..6e2abdf --- /dev/null +++ b/packages/github/tools/issues-get-comment.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// Define the input schema for the "Get an issue comment" endpoint. +const getIssueCommentSchema = z.object({ + owner: z.string().describe("The account owner of the repository. The name is not case sensitive."), + repo: z.string().describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + comment_id: z.number().int().describe("The unique identifier of the comment."), +}); + +type GetIssueCommentInput = z.infer; + +/** + * This function calls the GitHub API to get an issue comment. + * + * It uses the provided owner, repo, and comment_id to build the path URL. + * Authentication is handled via the metadata.auth.apiKey, which is attached as a Bearer token. + * + * @param input - An object containing the owner, repo, and comment_id. + * @param metadata - Contains authentication details. + * @returns A Result wrapping a string response on success, or an Error on failure. + */ +async function getIssueCommentFunction( + input: GetIssueCommentInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/issues/comments/${input.comment_id}`; + + const headers: Record = { + "Accept": "application/vnd.github.v3+json", + }; + + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + // Using ofetch to perform the GET request. + // parseResponse is set to return text for simplicity. + const res: string = await ofetch(url, { + method: "GET", + headers, + parseResponse: (txt) => txt, + }); + + return ok(res); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to fetch issue comment")); + } +} + +export const getIssueComment: ToolsetteTool = { + name: "get_issue_comment", + description: + "Get an issue comment. You can use the REST API to get comments on issues and pull requests. " + + "Every pull request is an issue, but not every issue is a pull request. " + + "This endpoint supports custom media types to return raw, text, or HTML representations of the comment body.", + parameters: getIssueCommentSchema, + function: getIssueCommentFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-get-label.test.ts b/packages/github/tools/issues-get-label.test.ts new file mode 100644 index 0000000..1cdc604 --- /dev/null +++ b/packages/github/tools/issues-get-label.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { getLabel } from "./gists-get_label"; +const getLabelFunction = getLabel.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run integration tests"); +} + +describe("getLabelFunction Integration Tests", () => { + const validAuth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + const noAuth = { type: "Bearer" as const, apiKey: "" }; + const invalidAuth = { type: "Bearer" as const, apiKey: "invalid-token" }; + + it("should fetch an existing label with valid authentication", async () => { + const result = await getLabelFunction( + { owner: "octocat", repo: "Hello-World", name: "bug" }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch an existing label without authentication header", async () => { + const result = await getLabelFunction( + { owner: "octocat", repo: "Hello-World", name: "bug" }, + { auth: noAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle non-existent label", async () => { + const result = await getLabelFunction( + { owner: "octocat", repo: "Hello-World", name: "non-existent-label-123456" }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid authentication", async () => { + const result = await getLabelFunction( + { owner: "octocat", repo: "Hello-World", name: "bug" }, + { auth: invalidAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch label in a case-insensitive manner", async () => { + const result = await getLabelFunction( + { owner: "octocat", repo: "Hello-World", name: "BuG" }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle non-existent repository", async () => { + const result = await getLabelFunction( + { owner: "octocat", repo: "non-existent-repo-123456", name: "bug" }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-get-label.ts b/packages/github/tools/issues-get-label.ts new file mode 100644 index 0000000..aeb66cb --- /dev/null +++ b/packages/github/tools/issues-get-label.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// Define the input parameter schema +const getLabelSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + name: z + .string() + .describe("The name of the label.") +}); + +type GetLabelInput = z.infer; + +/** + * Gets a label using the given name. + * + * @param input - Input parameters including owner, repo, and name of the label + * @param metadata - Contains authentication information, e.g. Bearer token in metadata.auth.apiKey + * @returns The label response as a string wrapped in a Result, or an error. + */ +async function getLabelFunction( + input: GetLabelInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/labels/${input.name}`; + + const headers: Record = { + "Accept": "application/vnd.github.v3+json" + }; + + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + const res = await ofetch(url, { + method: "GET", + headers, + parseResponse: (text) => text + }); + + return ok(res); + } catch (error) { + return err( + error instanceof Error ? error : new Error("Failed to fetch label") + ); + } +} + +export const getLabel: ToolsetteTool = { + name: "get_label", + description: "Gets a label using the given name.", + parameters: getLabelSchema, + function: getLabelFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-get.test.ts b/packages/github/tools/issues-get.test.ts new file mode 100644 index 0000000..9a709bc --- /dev/null +++ b/packages/github/tools/issues-get.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { getIssue } from "./gists-get_issue"; + +const getIssueFunction = getIssue.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +describe("getIssueFunction Integration Tests", () => { + const auth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + + it("should fetch an existing issue successfully", async () => { + const result = await getIssueFunction( + { owner: "octocat", repo: "Hello-World", issue_number: 1 }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error for a non-existent issue", async () => { + const result = await getIssueFunction( + { owner: "octocat", repo: "Hello-World", issue_number: 9999999 }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error for an invalid repository", async () => { + const result = await getIssueFunction( + { owner: "nonexistent", repo: "nonexistent", issue_number: 1 }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const invalidAuth = { type: "Bearer" as const, apiKey: "invalid-token" }; + const result = await getIssueFunction( + { owner: "octocat", repo: "Hello-World", issue_number: 1 }, + { auth: invalidAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error for negative issue number", async () => { + const result = await getIssueFunction( + { owner: "octocat", repo: "Hello-World", issue_number: -1 }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error for an extremely high issue number", async () => { + const result = await getIssueFunction( + { owner: "octocat", repo: "Hello-World", issue_number: Number.MAX_SAFE_INTEGER }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error for empty repository name", async () => { + const result = await getIssueFunction( + { owner: "octocat", repo: "", issue_number: 1 }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-get.ts b/packages/github/tools/issues-get.ts new file mode 100644 index 0000000..82428c2 --- /dev/null +++ b/packages/github/tools/issues-get.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// Define parameters schema +const getIssueSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + issue_number: z + .number() + .int() + .describe("The number that identifies the issue."), +}); + +type GetIssueInput = z.infer; + +// Implement the tool function +async function getIssueFunction( + input: GetIssueInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/issues/${input.issue_number}`; + const headers: Record = { + // Use the custom media type to get full response representations. + Accept: "application/vnd.github.full+json", + Authorization: `Bearer ${metadata.auth.apiKey}`, + }; + + const response = await ofetch(url, { + method: "GET", + headers, + // We return the raw text response. + parseResponse: (txt) => txt, + }); + + return ok(response); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to fetch issue")); + } +} + +// Export the tool object +export const getIssue: ToolsetteTool = { + name: "get_issue", + description: + "Get an issue. The API returns a 301 Moved Permanently status if the issue was transferred to another repository. If the issue was transferred to or deleted from a repository where the authenticated user lacks read access, the API returns a 404 Not Found status. If the issue was deleted from a repository where the authenticated user has read access, the API returns a 410 Gone status.", + parameters: getIssueSchema, + function: getIssueFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-list-comments-for-repo.test.ts b/packages/github/tools/issues-list-comments-for-repo.test.ts new file mode 100644 index 0000000..154765d --- /dev/null +++ b/packages/github/tools/issues-list-comments-for-repo.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { listIssueCommentsForRepo } from "./list-issue-comments-for-repo"; + +const listIssueCommentsForRepoFunction = listIssueCommentsForRepo.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +const validAuth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + +describe("listIssueCommentsForRepo Integration Tests", () => { + it("should fetch issue comments with default parameters", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + sort: "created", + per_page: 30, + page: 1, + }; + const result = await listIssueCommentsForRepoFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issue comments sorted by updated", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + sort: "updated", + per_page: 10, + page: 1, + }; + const result = await listIssueCommentsForRepoFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issue comments with direction parameter", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + sort: "created", + direction: "desc", + per_page: 5, + page: 1, + }; + const result = await listIssueCommentsForRepoFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issue comments with since parameter", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + since: "2023-01-01T00:00:00Z", + sort: "created", + per_page: 30, + page: 1, + }; + const result = await listIssueCommentsForRepoFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issue comments with per_page minimum (1)", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + sort: "created", + per_page: 1, + page: 1, + }; + const result = await listIssueCommentsForRepoFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issue comments with per_page maximum (100)", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + sort: "created", + per_page: 100, + page: 1, + }; + const result = await listIssueCommentsForRepoFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issue comments with page parameter greater than available results", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + sort: "created", + per_page: 30, + page: 1000, + }; + const result = await listIssueCommentsForRepoFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle error scenario with invalid repository", async () => { + const input = { + owner: "nonexistent-owner", + repo: "nonexistent-repo", + sort: "created", + per_page: 30, + page: 1, + }; + const result = await listIssueCommentsForRepoFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle authentication with invalid token", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + sort: "created", + per_page: 30, + page: 1, + }; + const invalidAuth = { type: "Bearer" as const, apiKey: "invalid-token" }; + const result = await listIssueCommentsForRepoFunction(input, { auth: invalidAuth }); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-list-comments-for-repo.ts b/packages/github/tools/issues-list-comments-for-repo.ts new file mode 100644 index 0000000..bf5e4b6 --- /dev/null +++ b/packages/github/tools/issues-list-comments-for-repo.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const listIssueCommentsForRepoSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + sort: z + .enum(["created", "updated"]) + .default("created") + .describe("The property to sort the results by."), + direction: z + .enum(["asc", "desc"]) + .optional() + .describe("Either `asc` or `desc`. Ignored without the `sort` parameter."), + since: z + .string() + .datetime() + .optional() + .describe("Only show results that were last updated after the given time. This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ."), + per_page: z + .number() + .int() + .min(1) + .max(100) + .default(30) + .describe('The number of results per page (max 100). For more information, see "Using pagination in the REST API".'), + page: z + .number() + .int() + .positive() + .default(1) + .describe('The page number of the results to fetch. For more information, see "Using pagination in the REST API".'), +}); + +type ListIssueCommentsForRepoInput = z.infer; + +async function listIssueCommentsForRepoFunction( + input: ListIssueCommentsForRepoInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const query: Record = { + sort: input.sort, + per_page: input.per_page, + page: input.page, + }; + + if (input.direction) { + query.direction = input.direction; + } + if (input.since) { + query.since = input.since; + } + + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/issues/comments`; + + const headers: Record = { + Accept: "application/vnd.github.v3+json", + }; + + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + const res = await ofetch(url, { + method: "GET", + query, + headers, + parseResponse: (txt) => txt, + }); + + return ok(res); + } catch (error) { + return err( + error instanceof Error ? error : new Error("Failed to list issue comments for repository") + ); + } +} + +export const listIssueCommentsForRepo: ToolsetteTool = { + name: "list_issue_comments_for_repo", + description: + "List issue comments for a repository. You can use the REST API to list comments on issues and pull requests for a repository. Every pull request is an issue, but not every issue is a pull request. By default, issue comments are ordered by ascending ID. This endpoint supports custom media types for raw markdown, text only, HTML rendered, and full representations.", + parameters: listIssueCommentsForRepoSchema, + function: listIssueCommentsForRepoFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-list-comments.test.ts b/packages/github/tools/issues-list-comments.test.ts new file mode 100644 index 0000000..f6e654f --- /dev/null +++ b/packages/github/tools/issues-list-comments.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { listIssueComments } from "./list_issue_comments"; + +const listIssueCommentsFunction = listIssueComments.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +describe("listIssueCommentsFunction Integration Tests", () => { + const auth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + + it("should fetch issue comments with default parameters", async () => { + const result = await listIssueCommentsFunction( + { + owner: "microsoft", + repo: "vscode", + issue_number: 100, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issue comments with custom pagination and 'since' parameter (likely empty result)", async () => { + const result = await listIssueCommentsFunction( + { + owner: "microsoft", + repo: "vscode", + issue_number: 100, + per_page: 1, + page: 1, + since: "2100-01-01T00:00:00Z", + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const result = await listIssueCommentsFunction( + { + owner: "microsoft", + repo: "vscode", + issue_number: 100, + }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle non-existent repository", async () => { + const result = await listIssueCommentsFunction( + { + owner: "microsoft", + repo: "non-existent-repo", + issue_number: 100, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle non-existent issue number", async () => { + const result = await listIssueCommentsFunction( + { + owner: "microsoft", + repo: "vscode", + issue_number: 9999999, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issue comments with per_page set to minimum value", async () => { + const result = await listIssueCommentsFunction( + { + owner: "microsoft", + repo: "vscode", + issue_number: 100, + per_page: 1, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issue comments with per_page set to maximum value", async () => { + const result = await listIssueCommentsFunction( + { + owner: "microsoft", + repo: "vscode", + issue_number: 100, + per_page: 100, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-list-comments.ts b/packages/github/tools/issues-list-comments.ts new file mode 100644 index 0000000..8e0fd06 --- /dev/null +++ b/packages/github/tools/issues-list-comments.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const listIssueCommentsSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + issue_number: z + .number() + .int() + .describe("The number that identifies the issue."), + since: z + .string() + .datetime() + .optional() + .describe("Only show results that were last updated after the given time. This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ."), + per_page: z + .number() + .int() + .default(30) + .describe("The number of results per page (max 100). For more information, see \"Using pagination in the REST API\"."), + page: z + .number() + .int() + .default(1) + .describe("The page number of the results to fetch. For more information, see \"Using pagination in the REST API\".") +}); + +type ListIssueCommentsInput = z.infer; + +async function listIssueCommentsFunction( + input: ListIssueCommentsInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + // Build query parameters from input + const query: Record = { + per_page: input.per_page, + page: input.page, + }; + if (input.since) { + query.since = input.since; + } + + // Construct the URL using path parameters + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/issues/${input.issue_number}/comments`; + + // Set the required headers, including Accept and Authorization + const headers: Record = { + Accept: "application/vnd.github.raw+json", // default media type for raw markdown body + }; + if (metadata.auth.apiKey) { + headers.Authorization = `Bearer ${metadata.auth.apiKey}`; + } + + // Make the GET request to GitHub API + const res = await ofetch(url, { + method: "GET", + query, + headers, + parseResponse: (responseText: string) => responseText, + }); + + return ok(res); + } catch (error) { + return err( + error instanceof Error ? error : new Error("Failed to list issue comments") + ); + } +} + +export const listIssueComments: ToolsetteTool = { + name: "list_issue_comments", + description: + "You can use the REST API to list comments on issues and pull requests. Every pull request is an issue, but not every issue is a pull request.\n\n" + + "Issue comments are ordered by ascending ID.\n\n" + + "This endpoint supports the following custom media types. For more information, see \"Media types\".\n\n" + + "- application/vnd.github.raw+json: Returns the raw markdown body. Response will include `body`. This is the default if you do not pass any specific media type.\n" + + "- application/vnd.github.text+json: Returns a text only representation of the markdown body. Response will include `body_text`.\n" + + "- application/vnd.github.html+json: Returns HTML rendered from the body's markdown. Response will include `body_html`.\n" + + "- application/vnd.github.full+json: Returns raw, text, and HTML representations. Response will include `body`, `body_text`, and `body_html`.", + parameters: listIssueCommentsSchema, + function: listIssueCommentsFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-list-labels-on-issue.test.ts b/packages/github/tools/issues-list-labels-on-issue.test.ts new file mode 100644 index 0000000..2230ddb --- /dev/null +++ b/packages/github/tools/issues-list-labels-on-issue.test.ts @@ -0,0 +1,145 @@ +```typescript +import { describe, it, expect } from "vitest"; +import { listLabelsForIssue } from "./list_labels_for_issue"; + +const listLabelsForIssueFunction = listLabelsForIssue.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +describe("listLabelsForIssueFunction Integration Tests", () => { + const auth = { + type: "Bearer" as const, + apiKey: GITHUB_TOKEN, + }; + + it("should fetch labels with default parameters", async () => { + const result = await listLabelsForIssueFunction( + { + owner: "octocat", + repo: "Hello-World", + issue_number: 1, + per_page: 30, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should respect per_page parameter", async () => { + const result = await listLabelsForIssueFunction( + { + owner: "octocat", + repo: "Hello-World", + issue_number: 1, + per_page: 1, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle pagination with different page parameter", async () => { + const result = await listLabelsForIssueFunction( + { + owner: "octocat", + repo: "Hello-World", + issue_number: 1, + per_page: 1, + page: 2, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const result = await listLabelsForIssueFunction( + { + owner: "octocat", + repo: "Hello-World", + issue_number: 1, + per_page: 30, + page: 1, + }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle non-existent repository", async () => { + const result = await listLabelsForIssueFunction( + { + owner: "octocat", + repo: "nonexistent-repo", + issue_number: 1, + per_page: 30, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle non-existent issue", async () => { + const result = await listLabelsForIssueFunction( + { + owner: "octocat", + repo: "Hello-World", + issue_number: 999999, + per_page: 30, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle edge case with per_page as 0", async () => { + const result = await listLabelsForIssueFunction( + { + owner: "octocat", + repo: "Hello-World", + issue_number: 1, + per_page: 0, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle edge case with high page number", async () => { + const result = await listLabelsForIssueFunction( + { + owner: "octocat", + repo: "Hello-World", + issue_number: 1, + per_page: 30, + page: 10000, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle per_page above maximum", async () => { + const result = await listLabelsForIssueFunction( + { + owner: "octocat", + repo: "Hello-World", + issue_number: 1, + per_page: 150, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); +``` \ No newline at end of file diff --git a/packages/github/tools/issues-list-labels-on-issue.ts b/packages/github/tools/issues-list-labels-on-issue.ts new file mode 100644 index 0000000..77bb8cb --- /dev/null +++ b/packages/github/tools/issues-list-labels-on-issue.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const listLabelsForIssueSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + issue_number: z + .number() + .int() + .describe("The number that identifies the issue."), + per_page: z + .number() + .int() + .default(30) + .describe( + "The number of results per page (max 100). For more information, see \"[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api).\"" + ), + page: z + .number() + .int() + .default(1) + .describe( + "The page number of the results to fetch. For more information, see \"[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api).\"" + ), +}); + +type ListLabelsForIssueInput = z.infer; + +async function listLabelsForIssueFunction( + input: ListLabelsForIssueInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/issues/${input.issue_number}/labels`; + const query = { + per_page: input.per_page, + page: input.page, + }; + + const headers: Record = { + Accept: "application/vnd.github+json", + }; + + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + const response = await ofetch(url, { + method: "GET", + query, + headers, + }); + + return ok(response); + } catch (error) { + return err( + error instanceof Error + ? error + : new Error("An error occurred while listing labels for the issue.") + ); + } +} + +export const listLabelsForIssue: ToolsetteTool = { + name: "list_labels_for_issue", + description: "Lists all labels for an issue.", + parameters: listLabelsForIssueSchema, + function: listLabelsForIssueFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-list.test.ts b/packages/github/tools/issues-list.test.ts new file mode 100644 index 0000000..c0f5dab --- /dev/null +++ b/packages/github/tools/issues-list.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from "vitest"; +import { listIssues } from "./issues-list"; + +const listIssuesFunction = listIssues.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +describe("listIssuesFunction Integration Tests", () => { + const auth = { + type: "Bearer" as const, + apiKey: GITHUB_TOKEN, + }; + + it("should fetch issues with default parameters", async () => { + const result = await listIssuesFunction( + { per_page: 30, page: 1 }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issues with custom filter, state, and labels", async () => { + const result = await listIssuesFunction( + { + filter: "created", + state: "closed", + labels: "bug,help wanted", + per_page: 5, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issues with pagination parameters", async () => { + const result = await listIssuesFunction( + { + per_page: 10, + page: 2, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issues with since parameter", async () => { + const result = await listIssuesFunction( + { + since: "2020-01-01T00:00:00Z", + per_page: 5, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issues with boolean parameters", async () => { + const result = await listIssuesFunction( + { + collab: true, + orgs: true, + owned: true, + pulls: true, + per_page: 5, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const result = await listIssuesFunction( + { per_page: 30, page: 1 }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle edge case: per_page over maximum", async () => { + const result = await listIssuesFunction( + { per_page: 150, page: 1 }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle edge case: page below minimum", async () => { + const result = await listIssuesFunction( + { per_page: 30, page: 0 }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch issues with a combination of multiple parameters", async () => { + const result = await listIssuesFunction( + { + filter: "mentioned", + state: "all", + labels: "enhancement,question", + sort: "comments", + direction: "asc", + since: "2021-01-01T00:00:00Z", + collab: false, + orgs: false, + owned: false, + pulls: false, + per_page: 5, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-list.ts b/packages/github/tools/issues-list.ts new file mode 100644 index 0000000..eff26e3 --- /dev/null +++ b/packages/github/tools/issues-list.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const listIssuesSchema = z.object({ + filter: z + .enum(["assigned", "created", "mentioned", "subscribed", "repos", "all"]) + .default("assigned") + .describe( + "Indicates which sorts of issues to return. `assigned` means issues assigned to you. `created` means issues created by you. `mentioned` means issues mentioning you. `subscribed` means issues you're subscribed to updates for. `all` or `repos` means all issues you can see, regardless of participation or creation." + ), + state: z + .enum(["open", "closed", "all"]) + .default("open") + .describe("Indicates the state of the issues to return."), + labels: z + .string() + .optional() + .describe("A list of comma separated label names. Example: `bug,ui,@high`"), + sort: z + .enum(["created", "updated", "comments"]) + .default("created") + .describe("What to sort results by."), + direction: z + .enum(["asc", "desc"]) + .default("desc") + .describe("The direction to sort the results by."), + since: z + .string() + .datetime() + .optional() + .describe( + "Only show results that were last updated after the given time. This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ." + ), + collab: z.boolean().optional(), + orgs: z.boolean().optional(), + owned: z.boolean().optional(), + pulls: z.boolean().optional(), + per_page: z + .number() + .int() + .default(30) + .describe( + "The number of results per page (max 100). For more information, see [Using pagination in the REST API](https://docs.github.com/rest/using-pagination-in-the-rest-api/using-pagination-in-the-rest-api)." + ), + page: z + .number() + .int() + .default(1) + .describe( + "The page number of the results to fetch. For more information, see [Using pagination in the REST API](https://docs.github.com/rest/using-pagination-in-the-rest-api/using-pagination-in-the-rest-api)." + ), +}); + +type ListIssuesInput = z.infer; + +async function listIssuesFunction( + input: ListIssuesInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const query = { + filter: input.filter, + state: input.state, + labels: input.labels, + sort: input.sort, + direction: input.direction, + since: input.since, + collab: input.collab, + orgs: input.orgs, + owned: input.owned, + pulls: input.pulls, + per_page: input.per_page, + page: input.page, + }; + + const headers = { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${metadata.auth.apiKey}`, + }; + + const res = await ofetch("https://api.github.com/issues", { + method: "GET", + query, + headers, + }); + + return ok(res); + } catch (error) { + return err( + error instanceof Error ? error : new Error("Failed to list issues") + ); + } +} + +export const listIssues: ToolsetteTool = { + name: "list_issues", + description: + "List issues assigned to the authenticated user across all visible repositories including owned repositories, member repositories, and organization repositories. You can use the `filter` query parameter to fetch issues that are not necessarily assigned to you.\n\n> [!NOTE]\n> GitHub's REST API considers every pull request an issue, but not every issue is a pull request. For this reason, \"Issues\" endpoints may return both issues and pull requests in the response. You can identify pull requests by the `pull_request` key. Be aware that the `id` of a pull request returned from \"Issues\" endpoints will be an _issue id_. To find out the pull request id, use the \"List pull requests\" endpoint.", + parameters: listIssuesSchema, + function: listIssuesFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-lock.test.ts b/packages/github/tools/issues-lock.test.ts new file mode 100644 index 0000000..e239f76 --- /dev/null +++ b/packages/github/tools/issues-lock.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { ofetch } from "ofetch"; +import { lockIssue } from "./lock_issue"; + +const lockIssueFunction = lockIssue.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const REPO_OWNER = process.env.GITHUB_REPO_OWNER; +const REPO_NAME = process.env.GITHUB_REPO_NAME; +const ISSUE_NUMBER = Number(process.env.GITHUB_ISSUE_NUMBER); + +if ( + !GITHUB_TOKEN || + !REPO_OWNER || + !REPO_NAME || + Number.isNaN(ISSUE_NUMBER) +) { + throw new Error( + "GITHUB_TOKEN, GITHUB_REPO_OWNER, GITHUB_REPO_NAME, and GITHUB_ISSUE_NUMBER environment variables are required to run tests" + ); +} + +async function unlockIssueDirectly(issue_number: number) { + const url = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/issues/${issue_number}/lock`; + const headers = { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${GITHUB_TOKEN}`, + }; + try { + await ofetch(url, { + method: "DELETE", + headers, + parseResponse: (text: string) => text, + }); + } catch (_) { + // ignore errors during unlock + } +} + +describe("lockIssueFunction Integration Tests", () => { + beforeEach(async () => { + // Ensure the primary test issue is unlocked before each test + await unlockIssueDirectly(ISSUE_NUMBER); + }); + + afterEach(async () => { + // Clean up: unlock the primary test issue after each test + await unlockIssueDirectly(ISSUE_NUMBER); + }); + + it("should lock an unlocked issue without lock_reason", async () => { + const input = { + owner: REPO_OWNER, + repo: REPO_NAME, + issue_number: ISSUE_NUMBER, + }; + const auth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + const result = await lockIssueFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should lock an unlocked issue with lock_reason", async () => { + const input = { + owner: REPO_OWNER, + repo: REPO_NAME, + issue_number: ISSUE_NUMBER, + lock_reason: "off-topic" as const, + }; + const auth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + const result = await lockIssueFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error when locking an already locked issue", async () => { + const input = { + owner: REPO_OWNER, + repo: REPO_NAME, + issue_number: ISSUE_NUMBER, + }; + const auth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + // First lock attempt should succeed. + await lockIssueFunction(input, { auth }); + // Second lock attempt on the already locked issue should error. + const result = await lockIssueFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error for non-existent issue (issue_number 0)", async () => { + const input = { + owner: REPO_OWNER, + repo: REPO_NAME, + issue_number: 0, + }; + const auth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + const result = await lockIssueFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error for an extremely large issue_number", async () => { + const input = { + owner: REPO_OWNER, + repo: REPO_NAME, + issue_number: Number.MAX_SAFE_INTEGER, + }; + const auth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + const result = await lockIssueFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const input = { + owner: REPO_OWNER, + repo: REPO_NAME, + issue_number: ISSUE_NUMBER, + }; + const auth = { type: "Bearer" as const, apiKey: "invalid-token" }; + const result = await lockIssueFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-lock.ts b/packages/github/tools/issues-lock.ts new file mode 100644 index 0000000..27c423b --- /dev/null +++ b/packages/github/tools/issues-lock.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const lockIssueSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + issue_number: z + .number() + .int() + .describe("The number that identifies the issue."), + lock_reason: z + .enum(["off-topic", "too heated", "resolved", "spam"]) + .optional() + .describe( + "The reason for locking the issue or pull request conversation. Lock will fail if you don't use one of these reasons: off-topic, too heated, resolved, spam. Example: off-topic" + ), +}); + +type LockIssueInput = z.infer; + +async function lockIssueFunction( + input: LockIssueInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/issues/${input.issue_number}/lock`; + + const headers: Record = { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${metadata.auth.apiKey}`, + }; + + let body: string = ""; + + // If a lock_reason is provided (and not null), include it in the JSON body, + // otherwise set Content-Length header to zero. + if (input.lock_reason != null) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify({ lock_reason: input.lock_reason }); + } else { + headers["Content-Length"] = "0"; + } + + const res = await ofetch(url, { + method: "PUT", + body, + headers, + // As the API returns a 204 status with no content, we'll simply return the response text. + parseResponse: (text: string) => text, + }); + + return ok(res); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to lock the issue")); + } +} + +export const lockIssue: ToolsetteTool = { + name: "lock_issue", + description: + "Users with push access can lock an issue or pull request's conversation.\n\nNote that, if you choose not to pass any parameters, you'll need to set `Content-Length` to zero when calling out to this endpoint. For more information, see \"[HTTP method](https://docs.github.com/rest/guides/getting-started-with-the-rest-api#http-method)\".", + parameters: lockIssueSchema, + function: lockIssueFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-remove-assignees.test.ts b/packages/github/tools/issues-remove-assignees.test.ts new file mode 100644 index 0000000..ed87ef7 --- /dev/null +++ b/packages/github/tools/issues-remove-assignees.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { removeAssignees } from "./gists-remove_assignees"; + +const removeAssigneesFunction = removeAssignees.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_OWNER = process.env.GITHUB_OWNER; +const GITHUB_REPO = process.env.GITHUB_REPO; +const GITHUB_ISSUE_NUMBER = process.env.GITHUB_ISSUE_NUMBER; +const GITHUB_ASSIGNEE = process.env.GITHUB_ASSIGNEE; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} +if (!GITHUB_OWNER) { + throw new Error("GITHUB_OWNER environment variable is required to run tests"); +} +if (!GITHUB_REPO) { + throw new Error("GITHUB_REPO environment variable is required to run tests"); +} +if (!GITHUB_ISSUE_NUMBER) { + throw new Error("GITHUB_ISSUE_NUMBER environment variable is required to run tests"); +} +if (!GITHUB_ASSIGNEE) { + throw new Error("GITHUB_ASSIGNEE environment variable is required to run tests"); +} + +const validAuth = { + type: "Bearer" as const, + apiKey: GITHUB_TOKEN, +}; + +const invalidAuth = { + type: "Bearer" as const, + apiKey: "invalid-token", +}; + +const emptyAuth = { + type: "Bearer" as const, + apiKey: "", +}; + +describe("removeAssigneesFunction Integration Tests", () => { + it("should successfully remove a single valid assignee", async () => { + const result = await removeAssigneesFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + issue_number: Number(GITHUB_ISSUE_NUMBER), + assignees: [GITHUB_ASSIGNEE], + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should successfully handle an empty assignees array", async () => { + const result = await removeAssigneesFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + issue_number: Number(GITHUB_ISSUE_NUMBER), + assignees: [], + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should successfully remove multiple assignees (one valid, one nonexistent)", async () => { + const result = await removeAssigneesFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + issue_number: Number(GITHUB_ISSUE_NUMBER), + assignees: [GITHUB_ASSIGNEE, "nonexistent-assignee"], + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail with an invalid auth token", async () => { + const result = await removeAssigneesFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + issue_number: Number(GITHUB_ISSUE_NUMBER), + assignees: [GITHUB_ASSIGNEE], + }, + { auth: invalidAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail with invalid repository details", async () => { + const result = await removeAssigneesFunction( + { + owner: "nonexistent-owner-xyz", + repo: "nonexistent-repo-xyz", + issue_number: 1, + assignees: [GITHUB_ASSIGNEE], + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail when auth token is missing", async () => { + const result = await removeAssigneesFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + issue_number: Number(GITHUB_ISSUE_NUMBER), + assignees: [GITHUB_ASSIGNEE], + }, + { auth: emptyAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail with an invalid issue number (0)", async () => { + const result = await removeAssigneesFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + issue_number: 0, + assignees: [GITHUB_ASSIGNEE], + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-remove-assignees.ts b/packages/github/tools/issues-remove-assignees.ts new file mode 100644 index 0000000..4b4831c --- /dev/null +++ b/packages/github/tools/issues-remove-assignees.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const removeAssigneesSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + issue_number: z + .number() + .int() + .describe("The number that identifies the issue."), + assignees: z + .array(z.string()) + .describe("Usernames of assignees to remove from an issue. _NOTE: Only users with push access can remove assignees from an issue. Assignees are silently ignored otherwise._") +}); + +type RemoveAssigneesInput = z.infer; + +async function removeAssigneesFunction( + input: RemoveAssigneesInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/issues/${input.issue_number}/assignees`; + const headers: Record = { + "Accept": "application/vnd.github.v3+json" + }; + + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + const res = await ofetch(url, { + method: "DELETE", + headers, + body: { assignees: input.assignees }, + parseResponse: (text) => text + }); + + return ok(res); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to remove assignees")); + } +} + +export const removeAssignees: ToolsetteTool = { + name: "remove_assignees", + description: "Removes one or more assignees from an issue.", + parameters: removeAssigneesSchema, + function: removeAssigneesFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-remove-label.test.ts b/packages/github/tools/issues-remove-label.test.ts new file mode 100644 index 0000000..bb20c9f --- /dev/null +++ b/packages/github/tools/issues-remove-label.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import { ofetch } from "ofetch"; +import { removeLabel } from "./issues-remove_label"; + +const removeLabelFunction = removeLabel.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const TEST_REPO_OWNER = process.env.TEST_REPO_OWNER; +const TEST_REPO_NAME = process.env.TEST_REPO_NAME; +const TEST_ISSUE_NUMBER = process.env.TEST_ISSUE_NUMBER ? Number(process.env.TEST_ISSUE_NUMBER) : NaN; + +if (!GITHUB_TOKEN || !TEST_REPO_OWNER || !TEST_REPO_NAME || !TEST_ISSUE_NUMBER) { + throw new Error("GITHUB_TOKEN, TEST_REPO_OWNER, TEST_REPO_NAME, and TEST_ISSUE_NUMBER environment variables are required to run tests"); +} + +async function addLabelToIssue(label: string, owner: string, repo: string, issue_number: number, token: string) { + const url = `https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}/labels`; + const headers = { + "Accept": "application/vnd.github.v3+json", + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + }; + return await ofetch(url, { + method: "POST", + headers, + body: JSON.stringify([label]), + parseResponse: (body) => body + }); +} + +describe("removeLabelFunction Integration Tests", () => { + const auth = { + type: "Bearer" as const, + apiKey: GITHUB_TOKEN, + }; + + it("should successfully remove an existing label from an issue", async () => { + // First add a label that we will remove. + const testLabel = "integration-test-label-success"; + await addLabelToIssue(testLabel, TEST_REPO_OWNER, TEST_REPO_NAME, TEST_ISSUE_NUMBER, GITHUB_TOKEN); + const result = await removeLabelFunction( + { + owner: TEST_REPO_OWNER, + repo: TEST_REPO_NAME, + issue_number: TEST_ISSUE_NUMBER, + name: testLabel, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return 404 when label does not exist", async () => { + const result = await removeLabelFunction( + { + owner: TEST_REPO_OWNER, + repo: TEST_REPO_NAME, + issue_number: TEST_ISSUE_NUMBER, + name: "non-existent-label-integration-test", + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should error with invalid auth token", async () => { + const result = await removeLabelFunction( + { + owner: TEST_REPO_OWNER, + repo: TEST_REPO_NAME, + issue_number: TEST_ISSUE_NUMBER, + name: "non-existent-label-integration-test", + }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle removal on non-existent issue", async () => { + const result = await removeLabelFunction( + { + owner: TEST_REPO_OWNER, + repo: TEST_REPO_NAME, + issue_number: 99999999, + name: "integration-test-label", + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle boundary case with issue_number as 0", async () => { + const result = await removeLabelFunction( + { + owner: TEST_REPO_OWNER, + repo: TEST_REPO_NAME, + issue_number: 0, + name: "integration-test-label-boundary", + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should remove one label leaving others intact", async () => { + const labelToKeep = "integration-test-label-keep"; + const labelToRemove = "integration-test-label-remove"; + // Add both labels + await addLabelToIssue(labelToKeep, TEST_REPO_OWNER, TEST_REPO_NAME, TEST_ISSUE_NUMBER, GITHUB_TOKEN); + await addLabelToIssue(labelToRemove, TEST_REPO_OWNER, TEST_REPO_NAME, TEST_ISSUE_NUMBER, GITHUB_TOKEN); + // Remove one label + const result = await removeLabelFunction( + { + owner: TEST_REPO_OWNER, + repo: TEST_REPO_NAME, + issue_number: TEST_ISSUE_NUMBER, + name: labelToRemove, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-remove-label.ts b/packages/github/tools/issues-remove-label.ts new file mode 100644 index 0000000..95f202c --- /dev/null +++ b/packages/github/tools/issues-remove-label.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// Create a zod schema for the input parameters +const removeLabelSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + issue_number: z + .number() + .int() + .describe("The number that identifies the issue."), + name: z + .string() + .describe("The name of the label to remove") +}); + +type RemoveLabelInput = z.infer; + +/** + * Removes the specified label from an issue and returns the remaining labels. + * This endpoint returns a 404 Not Found status if the label does not exist. + */ +async function removeLabelFunction( + input: RemoveLabelInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/issues/${input.issue_number}/labels/${input.name}`; + + const headers: { [key: string]: string } = { + "Accept": "application/vnd.github.v3+json", + }; + + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + const response = await ofetch(url, { + method: "DELETE", + headers, + // The API returns JSON (an array of label objects) on success. + parseResponse: (body) => body + }); + + return ok(response); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to remove label from issue")); + } +} + +export const removeLabel: ToolsetteTool = { + name: "remove_label", + description: + "Removes the specified label from the issue, and returns the remaining labels on the issue. This endpoint returns a 404 Not Found status if the label does not exist.", + parameters: removeLabelSchema, + function: removeLabelFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-unlock.test.ts b/packages/github/tools/issues-unlock.test.ts new file mode 100644 index 0000000..5f36d5c --- /dev/null +++ b/packages/github/tools/issues-unlock.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { ofetch } from "ofetch"; +import { unlockIssue } from "./unlock_issue"; + +const unlockIssueFunction = unlockIssue.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_OWNER = process.env.GITHUB_OWNER; +const GITHUB_REPO = process.env.GITHUB_REPO; +const ISSUE_NUMBER_STR = process.env.GITHUB_ISSUE_NUMBER; + +if (!GITHUB_TOKEN) + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +if (!GITHUB_OWNER) + throw new Error("GITHUB_OWNER environment variable is required to run tests"); +if (!GITHUB_REPO) + throw new Error("GITHUB_REPO environment variable is required to run tests"); +if (!ISSUE_NUMBER_STR) + throw new Error("GITHUB_ISSUE_NUMBER environment variable is required to run tests"); + +const GITHUB_ISSUE_NUMBER = Number(ISSUE_NUMBER_STR); +if (isNaN(GITHUB_ISSUE_NUMBER)) + throw new Error("GITHUB_ISSUE_NUMBER must be a number"); + +describe("unlockIssueFunction Integration Tests", () => { + const auth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + + it("should successfully unlock a locked issue with valid parameters", async () => { + // Lock the issue first to ensure it is locked + const lockUrl = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/issues/${GITHUB_ISSUE_NUMBER}/lock`; + await ofetch(lockUrl, { + method: "PUT", + headers: { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${GITHUB_TOKEN}`, + }, + }); + + const result = await unlockIssueFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + issue_number: GITHUB_ISSUE_NUMBER, + }, + { auth } + ); + + expect(result).toMatchInlineSnapshot(` + { + "_tag": "Ok", + "value": "Issue unlocked successfully", + } + `); + }); + + it("should handle unlocking an already unlocked issue", async () => { + // The issue should now be unlocked from the previous test + const result = await unlockIssueFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + issue_number: GITHUB_ISSUE_NUMBER, + }, + { auth } + ); + + expect(result).toMatchInlineSnapshot(` + { + "_tag": "Err", + "error": [Error: Not Found], + } + `); + }); + + it("should return error with invalid authentication", async () => { + const result = await unlockIssueFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + issue_number: GITHUB_ISSUE_NUMBER, + }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + + expect(result).toMatchInlineSnapshot(` + { + "_tag": "Err", + "error": [Error: Bad credentials], + } + `); + }); + + it("should return error for non-existent repository", async () => { + const result = await unlockIssueFunction( + { + owner: "nonexistentowner123", + repo: "nonexistentrepo123", + issue_number: 1, + }, + { auth } + ); + + expect(result).toMatchInlineSnapshot(` + { + "_tag": "Err", + "error": [Error: Not Found], + } + `); + }); + + it("should return error for issue_number as 0", async () => { + const result = await unlockIssueFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + issue_number: 0, + }, + { auth } + ); + + expect(result).toMatchInlineSnapshot(` + { + "_tag": "Err", + "error": [Error: Not Found], + } + `); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-unlock.ts b/packages/github/tools/issues-unlock.ts new file mode 100644 index 0000000..a9bc944 --- /dev/null +++ b/packages/github/tools/issues-unlock.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const unlockIssueSchema = z.object({ + owner: z + .string() + .describe( + "The account owner of the repository. The name is not case sensitive." + ), + repo: z + .string() + .describe( + "The name of the repository without the `.git` extension. The name is not case sensitive." + ), + issue_number: z + .number() + .describe("The number that identifies the issue."), +}); + +type UnlockIssueInput = z.infer; + +async function unlockIssueFunction( + input: UnlockIssueInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const { owner, repo, issue_number } = input; + const url = `https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}/lock`; + + const headers = { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${metadata.auth.apiKey}`, + }; + + await ofetch(url, { + method: "DELETE", + headers, + parseResponse: (text: string) => text, + }); + + return ok("Issue unlocked successfully"); + } catch (error) { + return err( + error instanceof Error ? error : new Error("Failed to unlock issue") + ); + } +} + +export const unlockIssue: ToolsetteTool = { + name: "unlock_issue", + description: "Users with push access can unlock an issue's conversation.", + parameters: unlockIssueSchema, + function: unlockIssueFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-update-comment.test.ts b/packages/github/tools/issues-update-comment.test.ts new file mode 100644 index 0000000..6870859 --- /dev/null +++ b/packages/github/tools/issues-update-comment.test.ts @@ -0,0 +1,119 @@ +```typescript +import { describe, it, expect } from "vitest"; +import { updateIssueComment } from "./update_issue_comment"; + +const updateIssueCommentFunction = updateIssueComment.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_OWNER = process.env.GITHUB_OWNER; +const GITHUB_REPO = process.env.GITHUB_REPO; +const GITHUB_COMMENT_ID = process.env.GITHUB_COMMENT_ID ? Number(process.env.GITHUB_COMMENT_ID) : undefined; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} +if (!GITHUB_OWNER) { + throw new Error("GITHUB_OWNER environment variable is required to run tests"); +} +if (!GITHUB_REPO) { + throw new Error("GITHUB_REPO environment variable is required to run tests"); +} +if (GITHUB_COMMENT_ID === undefined) { + throw new Error("GITHUB_COMMENT_ID environment variable is required to run tests"); +} + +const validAuth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + +describe("updateIssueCommentFunction Integration Tests", () => { + it("should update an issue comment successfully with new body", async () => { + const result = await updateIssueCommentFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + comment_id: GITHUB_COMMENT_ID, + body: "Updated comment body test: success case", + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should update an issue comment successfully with another valid body", async () => { + const result = await updateIssueCommentFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + comment_id: GITHUB_COMMENT_ID, + body: "Another update test comment", + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail updating an issue comment with invalid auth token", async () => { + const result = await updateIssueCommentFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + comment_id: GITHUB_COMMENT_ID, + body: "Test update with invalid token", + }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail updating an issue comment with non-existent comment id", async () => { + const result = await updateIssueCommentFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + comment_id: 999999999, + body: "Test update with non-existent comment id", + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail updating an issue comment with invalid repository", async () => { + const result = await updateIssueCommentFunction( + { + owner: GITHUB_OWNER, + repo: "non-existent-repo", + comment_id: 1, + body: "Test update with invalid repository", + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should update an issue comment with empty body", async () => { + const result = await updateIssueCommentFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + comment_id: GITHUB_COMMENT_ID, + body: "", + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail updating an issue comment with comment_id boundary value 0", async () => { + const result = await updateIssueCommentFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + comment_id: 0, + body: "Test comment_id boundary", + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); +``` \ No newline at end of file diff --git a/packages/github/tools/issues-update-comment.ts b/packages/github/tools/issues-update-comment.ts new file mode 100644 index 0000000..c527376 --- /dev/null +++ b/packages/github/tools/issues-update-comment.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// This schema combines all input parameters from the path and the JSON request body. +const updateIssueCommentSchema = z.object({ + owner: z.string().describe("The account owner of the repository. The name is not case sensitive."), + repo: z.string().describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + comment_id: z.number().int().describe("The unique identifier of the comment."), + body: z.string().describe("The contents of the comment. Example: 'Me too'") +}); + +type UpdateIssueCommentInput = z.infer; + +async function updateIssueCommentFunction( + input: UpdateIssueCommentInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + // Construct the URL using the path parameters. + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/issues/comments/${input.comment_id}`; + + // Set up default headers. Using the GitHub media type that returns raw markdown by default. + const headers: Record = { + "Accept": "application/vnd.github.raw+json" + }; + + // Handle authentication using the provided API key from metadata. + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + // Send the PATCH request with the comment body in the JSON payload. + const res = await ofetch(url, { + method: "PATCH", + headers, + body: { body: input.body }, + parseResponse: (txt) => txt, + }); + + return ok(res); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to update issue comment")); + } +} + +export const updateIssueComment: ToolsetteTool = { + name: "update_issue_comment", + description: + "Update an issue comment: You can use the REST API to update comments on issues and pull requests. " + + "Every pull request is an issue, but not every issue is a pull request. This endpoint supports custom media types " + + "that return raw, text, HTML, or full representations of the comment.", + parameters: updateIssueCommentSchema, + function: updateIssueCommentFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-update-label.test.ts b/packages/github/tools/issues-update-label.test.ts new file mode 100644 index 0000000..d6f1bd3 --- /dev/null +++ b/packages/github/tools/issues-update-label.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import { updateLabel } from "./update_label"; + +const updateLabelFunction = updateLabel.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const OWNER = process.env.GITHUB_REPO_OWNER; +const REPO = process.env.GITHUB_REPO_NAME; +const ORIGINAL_LABEL_NAME = process.env.GITHUB_LABEL_NAME; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} +if (!OWNER) { + throw new Error("GITHUB_REPO_OWNER environment variable is required to run tests"); +} +if (!REPO) { + throw new Error("GITHUB_REPO_NAME environment variable is required to run tests"); +} +if (!ORIGINAL_LABEL_NAME) { + throw new Error("GITHUB_LABEL_NAME environment variable is required to run tests"); +} + +describe("updateLabelFunction Integration Tests", () => { + const auth = { + type: "Bearer" as const, + apiKey: GITHUB_TOKEN, + }; + + it("should update a label's new_name, description and color and then revert the changes", async () => { + const tempNewName = `${ORIGINAL_LABEL_NAME}-temp-update`; + const updateResult = await updateLabelFunction( + { + owner: OWNER, + repo: REPO, + name: ORIGINAL_LABEL_NAME, + new_name: tempNewName, + description: "Temporary updated description", + color: "abcdef", + }, + { auth } + ); + expect(updateResult).toMatchInlineSnapshot(); + const revertResult = await updateLabelFunction( + { + owner: OWNER, + repo: REPO, + name: tempNewName, + new_name: ORIGINAL_LABEL_NAME, + description: "Reverted description", + color: "000000", + }, + { auth } + ); + expect(revertResult).toMatchInlineSnapshot(); + }); + + it("should update label with empty payload (no changes)", async () => { + const result = await updateLabelFunction( + { + owner: OWNER, + repo: REPO, + name: ORIGINAL_LABEL_NAME, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error when updating a non-existent label", async () => { + const result = await updateLabelFunction( + { + owner: OWNER, + repo: REPO, + name: "nonexistent-label-123456", + description: "Should fail update", + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail when using an invalid auth token", async () => { + const invalidAuth = { + type: "Bearer" as const, + apiKey: "invalid-token", + }; + const result = await updateLabelFunction( + { + owner: OWNER, + repo: REPO, + name: ORIGINAL_LABEL_NAME, + description: "Attempt update with invalid auth", + }, + { auth: invalidAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should update label with maximum length description", async () => { + const longDescription = "a".repeat(100); + const tempNewName = `${ORIGINAL_LABEL_NAME}-long-desc`; + const updateResult = await updateLabelFunction( + { + owner: OWNER, + repo: REPO, + name: ORIGINAL_LABEL_NAME, + new_name: tempNewName, + description: longDescription, + }, + { auth } + ); + expect(updateResult).toMatchInlineSnapshot(); + const revertResult = await updateLabelFunction( + { + owner: OWNER, + repo: REPO, + name: tempNewName, + new_name: ORIGINAL_LABEL_NAME, + }, + { auth } + ); + expect(revertResult).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-update-label.ts b/packages/github/tools/issues-update-label.ts new file mode 100644 index 0000000..8378ab5 --- /dev/null +++ b/packages/github/tools/issues-update-label.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +/** + * Schema for updating a label. + * + * Parameters: + * - owner: The account owner of the repository. The name is not case sensitive. + * - repo: The name of the repository without the `.git` extension. The name is not case sensitive. + * - name: The current name of the label to update. + * - new_name (optional): The new name of the label. Emoji can be added to label names, using either native emoji or colon-style markup. + * For example, typing `:strawberry:` will render the emoji ![:strawberry:](https://github.githubassets.com/images/icons/emoji/unicode/1f353.png ":strawberry:"). + * For a full list of available emoji and codes, see "[Emoji cheat sheet](https://github.com/ikatyang/emoji-cheat-sheet)." + * - color (optional): The hexadecimal color code for the label, without the leading `#`. + * - description (optional): A short description of the label. Must be 100 characters or fewer. + * + * Request body example: + * { + * "new_name": "bug :bug:", + * "description": "Small bug fix required", + * "color": "b01f26" + * } + */ +const updateLabelSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + name: z.string().describe("The current name of the label to update."), + new_name: z + .string() + .optional() + .describe( + "The new name of the label. Emoji can be added to label names, using either native emoji or colon-style markup. For example, typing `:strawberry:` will render the emoji ![:strawberry:](https://github.githubassets.com/images/icons/emoji/unicode/1f353.png \":strawberry:\"). For a full list of available emoji and codes, see \"[Emoji cheat sheet](https://github.com/ikatyang/emoji-cheat-sheet).\"" + ), + color: z + .string() + .optional() + .describe("The [hexadecimal color code](http://www.color-hex.com/) for the label, without the leading `#`."), + description: z + .string() + .optional() + .describe("A short description of the label. Must be 100 characters or fewer."), +}); + +type UpdateLabelInput = z.infer; + +/** + * Interface for the Label returned by GitHub. + */ +interface Label { + id: number; + node_id: string; + url: string; + name: string; + description: string | null; + color: string; + default: boolean; +} + +/** + * Updates a label on a GitHub repository using the provided parameters. + * + * This function sends a PATCH request to the GitHub API endpoint: + * PATCH /repos/{owner}/{repo}/labels/{name} + * + * It uses the provided authentication token from metadata. + * + * @param input UpdateLabelInput - The input parameters including path parameters and optional request body properties. + * @param metadata Auth metadata including a Bearer API key. + * @returns A Promise resolving to a Result containing the updated Label object or an Error. + */ +async function updateLabelFunction( + input: UpdateLabelInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/labels/${encodeURIComponent( + input.name + )}`; + + // Build the payload for the PATCH request body. + const payload: Record = {}; + if (input.new_name) payload.new_name = input.new_name; + if (input.color) payload.color = input.color; + if (input.description) payload.description = input.description; + + // Set up the headers including authentication. + const headers: Record = { + Accept: "application/vnd.github.v3+json", + "Content-Type": "application/json", + }; + + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + const res = await ofetch(url, { + method: "PATCH", + headers, + // Only include the body if there are properties to update. + body: Object.keys(payload).length > 0 ? payload : undefined, + }); + + return ok(res as Label); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to update label")); + } +} + +export const updateLabel: ToolsetteTool = { + name: "update_label", + description: "Updates a label using the given label name.", + parameters: updateLabelSchema, + function: updateLabelFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/issues-update.test.ts b/packages/github/tools/issues-update.test.ts new file mode 100644 index 0000000..de626a3 --- /dev/null +++ b/packages/github/tools/issues-update.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from "vitest"; +import { updateIssue } from "./update_issue"; + +const updateIssueFunction = updateIssue.function; + +const { + GITHUB_TOKEN, + GITHUB_TEST_REPO_OWNER, + GITHUB_TEST_REPO_NAME, + GITHUB_TEST_ISSUE_NUMBER, +} = process.env; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} +if (!GITHUB_TEST_REPO_OWNER) { + throw new Error("GITHUB_TEST_REPO_OWNER environment variable is required to run tests"); +} +if (!GITHUB_TEST_REPO_NAME) { + throw new Error("GITHUB_TEST_REPO_NAME environment variable is required to run tests"); +} +if (!GITHUB_TEST_ISSUE_NUMBER) { + throw new Error("GITHUB_TEST_ISSUE_NUMBER environment variable is required to run tests"); +} + +const issueNumber = Number(GITHUB_TEST_ISSUE_NUMBER); +const validAuth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + +describe("updateIssueFunction Integration Tests", () => { + it("should update the issue title successfully", async () => { + const newTitle = `Updated Title ${new Date().toISOString()}`; + const result = await updateIssueFunction( + { + owner: GITHUB_TEST_REPO_OWNER, + repo: GITHUB_TEST_REPO_NAME, + issue_number: issueNumber, + title: newTitle, + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should update the issue body successfully", async () => { + const result = await updateIssueFunction( + { + owner: GITHUB_TEST_REPO_OWNER, + repo: GITHUB_TEST_REPO_NAME, + issue_number: issueNumber, + body: `Updated body content at ${new Date().toISOString()}`, + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should close the issue successfully", async () => { + const result = await updateIssueFunction( + { + owner: GITHUB_TEST_REPO_OWNER, + repo: GITHUB_TEST_REPO_NAME, + issue_number: issueNumber, + state: "closed", + state_reason: "completed", + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should reopen the issue successfully", async () => { + const result = await updateIssueFunction( + { + owner: GITHUB_TEST_REPO_OWNER, + repo: GITHUB_TEST_REPO_NAME, + issue_number: issueNumber, + state: "open", + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should update the issue without additional fields", async () => { + const result = await updateIssueFunction( + { + owner: GITHUB_TEST_REPO_OWNER, + repo: GITHUB_TEST_REPO_NAME, + issue_number: issueNumber, + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle title as a number", async () => { + const result = await updateIssueFunction( + { + owner: GITHUB_TEST_REPO_OWNER, + repo: GITHUB_TEST_REPO_NAME, + issue_number: issueNumber, + title: 12345, + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should update multiple fields including body, assignees, and labels", async () => { + const result = await updateIssueFunction( + { + owner: GITHUB_TEST_REPO_OWNER, + repo: GITHUB_TEST_REPO_NAME, + issue_number: issueNumber, + body: "Multi-field update body", + assignees: [GITHUB_TEST_REPO_OWNER], + labels: [ + "bug", + { id: 123, name: "enhancement", description: "enhancement label", color: "f29513" } + ], + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid repository", async () => { + const result = await updateIssueFunction( + { + owner: GITHUB_TEST_REPO_OWNER, + repo: "nonexistent-repo-for-testing", + issue_number: 1, + title: "Test update for invalid repo", + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid authentication", async () => { + const invalidAuth = { type: "Bearer" as const, apiKey: "invalid-token" }; + const result = await updateIssueFunction( + { + owner: GITHUB_TEST_REPO_OWNER, + repo: GITHUB_TEST_REPO_NAME, + issue_number: issueNumber, + title: "Test update for invalid auth", + }, + { auth: invalidAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/issues-update.ts b/packages/github/tools/issues-update.ts new file mode 100644 index 0000000..1b82db9 --- /dev/null +++ b/packages/github/tools/issues-update.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// Define a schema for all input parameters including path parameters and request body fields. +const updateIssueSchema = z.object({ + // Path parameters + owner: z.string().describe("The account owner of the repository. The name is not case sensitive."), + repo: z.string().describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + issue_number: z.number().int().describe("The number that identifies the issue."), + + // Request body fields (all optional) + title: z.union([z.string(), z.number()]).nullable().optional().describe("The title of the issue."), + body: z.string().nullable().optional().describe("The contents of the issue."), + assignee: z.string().nullable().optional().describe("Username to assign to this issue. **This field is closing down.**"), + state: z.enum(["open", "closed"]).optional().describe("The open or closed state of the issue."), + state_reason: z.union([z.literal("completed"), z.literal("not_planned"), z.literal("reopened"), z.null()]).optional().describe("The reason for the state change. Ignored unless `state` is changed."), + milestone: z.union([z.string(), z.number()]).nullable().optional().describe("The `number` of the milestone to associate this issue with or use `null` to remove the current milestone. Only users with push access can set the milestone for issues. Without push access to the repository, milestone changes are silently dropped."), + labels: z.array( + z.union([ + z.string(), + z.object({ + id: z.number().describe("The unique identifier of the label."), + name: z.string().describe("The name of the label."), + description: z.string().nullable().optional().describe("Description of the label."), + color: z.string().nullable().optional().describe("Color of the label.") + }) + ]) + ).optional().describe("Labels to associate with this issue. Pass one or more labels to _replace_ the set of labels on this issue. Send an empty array (`[]`) to clear all labels from the issue. Only users with push access can set labels for issues. Without push access to the repository, label changes are silently dropped."), + assignees: z.array(z.string()).optional().describe("Usernames to assign to this issue. Pass one or more user logins to _replace_ the set of assignees on this issue. Send an empty array (`[]`) to clear all assignees from the issue. Only users with push access can set assignees for new issues. Without push access to the repository, assignee changes are silently dropped.") +}); + +type UpdateIssueInput = z.infer; + +/** + * Updates an issue in a repository. + * + * Issue owners and users with push access or Triage role can edit an issue. + * This endpoint supports several custom media types for different representations of the issue body. + */ +async function updateIssueFunction( + input: UpdateIssueInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + // Destructure the path parameters from the input and treat the remaining fields as the request body payload. + const { owner, repo, issue_number, ...bodyPayload } = input; + const url = `https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`; + + const response = await ofetch(url, { + method: "PATCH", + headers: { + "Authorization": `Bearer ${metadata.auth.apiKey}`, + "Accept": "application/vnd.github.v3+json", + "Content-Type": "application/json" + }, + // ofetch automatically serializes the body to JSON + body: bodyPayload, + parseResponse: (text: string) => text + }); + + return ok(response); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to update issue")); + } +} + +export const updateIssue: ToolsetteTool = { + name: "update_issue", + description: + "Issue owners and users with push access or Triage role can edit an issue. This endpoint supports custom media types (raw, text, HTML, and full representations) for the issue body.", + parameters: updateIssueSchema, + function: updateIssueFunction +}; \ No newline at end of file diff --git a/packages/github/tools/listGists.evals.ts b/packages/github/tools/listGists.evals.ts deleted file mode 100644 index 469c403..0000000 --- a/packages/github/tools/listGists.evals.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { expect, test } from "vitest"; -import { generateText } from "ai"; -import { createAnthropic } from "@ai-sdk/anthropic"; - -import { listGists } from "./listGists"; -import { format, withAuth } from "@toolsette/utils"; - -const anthropic = createAnthropic({ - apiKey: process.env.ANTHROPIC_API_KEY!, -}); - -const model = anthropic("claude-3-5-sonnet-latest"); - -const toolsWithAuth = withAuth([listGists], { - type: "Bearer", - apiKey: process.env.GITHUB_API_KEY!, -}); - -const listGistsAISdk = format(toolsWithAuth, "ai-sdk"); - -test("Show me all my gists from the last 24 hours", async () => { - const res = await generateText({ - model, - prompt: `right now the time is Thu 30 Jan, 9:37 PM, show me all my gists from the last 24 hours`, - tools: listGistsAISdk, - }); - - expect( - res.toolCalls.map((call) => ({ - args: call.args, - toolName: call.toolName, - type: call.type, - })) - ).toMatchInlineSnapshot(` - [ - { - "args": { - "page": 1, - "per_page": 30, - "since": "2024-01-29T21:37:00Z", - }, - "toolName": "list_gists", - "type": "tool-call", - }, - ] - `); -}); - -test("get me the first 50 gists", async () => { - const res = await generateText({ - model, - prompt: `get me my first 50 gists`, - tools: listGistsAISdk, - }); - - expect( - res.toolCalls.map((call) => ({ - args: call.args, - toolName: call.toolName, - type: call.type, - })) - ).toMatchInlineSnapshot(` - [ - { - "args": { - "page": 1, - "per_page": 50, - }, - "toolName": "list_gists", - "type": "tool-call", - }, - ] - `); -}); - -test("Show me the third page of my gists, 20 gists per page", async () => { - const res = await generateText({ - model, - prompt: `show me the third page of my gists, 20 gists per page`, - tools: listGistsAISdk, - }); - - expect( - res.toolCalls.map((call) => ({ - args: call.args, - toolName: call.toolName, - type: call.type, - })) - ).toMatchInlineSnapshot(` - [ - { - "args": { - "page": 3, - "per_page": 20, - }, - "toolName": "list_gists", - "type": "tool-call", - }, - ] - `); -}); - -test("Get me the maximum number of gists possible in one request", async () => { - const res = await generateText({ - model, - prompt: `get me the maximum number of gists possible in one request`, - tools: listGistsAISdk, - }); - - expect( - res.toolCalls.map((call) => ({ - args: call.args, - toolName: call.toolName, - type: call.type, - })) - ).toMatchInlineSnapshot(` - [ - { - "args": { - "page": 1, - "per_page": 100, - }, - "toolName": "list_gists", - "type": "tool-call", - }, - ] - `); -}); - -// TODO: add more complex evals -// some observations on writing evals for listGists: -// I want to mock the output of the listGists and makes sure the pagination happens properly. -// I want to execute the listGist tool with a mock response and see if the model properly works with the output. -// If i'm extending vitest to have ai features, these would be good scenarios to consider. diff --git a/packages/github/tools/pulls-create-review.test.ts b/packages/github/tools/pulls-create-review.test.ts new file mode 100644 index 0000000..8ffd530 --- /dev/null +++ b/packages/github/tools/pulls-create-review.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { create_pull_request_review } from "./create_pull_request_review"; + +const createPullRequestReviewFunction = create_pull_request_review.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_OWNER = process.env.GITHUB_OWNER; +const GITHUB_REPO = process.env.GITHUB_REPO; +const GITHUB_PULL_NUMBER = process.env.GITHUB_PULL_NUMBER; + +if (!GITHUB_TOKEN || !GITHUB_OWNER || !GITHUB_REPO || !GITHUB_PULL_NUMBER) { + throw new Error("GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO, and GITHUB_PULL_NUMBER environment variables are required to run tests"); +} + +const pullNumber = Number(GITHUB_PULL_NUMBER); + +describe("createPullRequestReviewFunction Integration Tests", () => { + const validAuth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + + it("should create a pending review with minimal parameters", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + pull_number: pullNumber, + }; + + const result = await createPullRequestReviewFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should create an approved review when event is APPROVE", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + pull_number: pullNumber, + event: "APPROVE" as const, + }; + + const result = await createPullRequestReviewFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should create a review with inline comments", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + pull_number: pullNumber, + comments: [ + { + path: "README.md", + line: 5, + body: "Test inline comment", + }, + ], + }; + + const result = await createPullRequestReviewFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + pull_number: pullNumber, + }; + + const invalidAuth = { type: "Bearer" as const, apiKey: "invalid-token" }; + const result = await createPullRequestReviewFunction(input, { auth: invalidAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error when pull request does not exist", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + pull_number: 9999999, + }; + + const result = await createPullRequestReviewFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should create a review with both body and inline comments for COMMENT event", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + pull_number: pullNumber, + event: "COMMENT" as const, + body: "Review comment", + comments: [ + { + path: "src/app.js", + body: "Inline code review", + position: 5, + }, + ], + }; + + const result = await createPullRequestReviewFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid commit_id gracefully", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + pull_number: pullNumber, + commit_id: "invalid-sha", + event: "APPROVE" as const, + }; + + const result = await createPullRequestReviewFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle edge case with negative line number in inline comment", async () => { + const input = { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + pull_number: pullNumber, + comments: [ + { + path: "README.md", + line: -1, + body: "Bad inline comment", + }, + ], + }; + + const result = await createPullRequestReviewFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/pulls-create-review.ts b/packages/github/tools/pulls-create-review.ts new file mode 100644 index 0000000..6124f60 --- /dev/null +++ b/packages/github/tools/pulls-create-review.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// Schema for individual review comments +const reviewCommentSchema = z.object({ + path: z + .string() + .describe("The relative path to the file that necessitates a review comment."), + position: z + .number() + .int() + .optional() + .describe( + 'The position in the diff where you want to add a review comment. Note this is not the same as the line number in the file. The position value equals the number of lines down from the first "@@" hunk header; the line just below the header is position 1, the next is 2, and so on.' + ), + body: z.string().describe("Text of the review comment."), + line: z + .number() + .int() + .optional() + .describe("The line in the file. Example: 28"), + side: z.string().optional().describe("Side, for example: RIGHT"), + start_line: z + .number() + .int() + .optional() + .describe("The start line in the diff. Example: 26"), + start_side: z.string().optional().describe("The start side. Example: LEFT"), +}); + +// Main input schema combining path parameters and optional request body parameters +const createReviewSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + pull_number: z + .number() + .int() + .describe("The number that identifies the pull request."), + commit_id: z + .string() + .optional() + .describe( + "The SHA of the commit that needs a review. Not using the latest commit SHA may render your review comment outdated if a subsequent commit modifies the line you specify as the `position`. Defaults to the most recent commit in the pull request when you do not specify a value." + ), + body: z + .string() + .optional() + .describe( + "**Required** when using `REQUEST_CHANGES` or `COMMENT` for the `event` parameter. The body text of the pull request review." + ), + event: z + .enum(["APPROVE", "REQUEST_CHANGES", "COMMENT"]) + .optional() + .describe( + "The review action you want to perform. The review actions include: `APPROVE`, `REQUEST_CHANGES`, or `COMMENT`. By leaving this blank, the review action state will be set to `PENDING`, meaning you will need to submit the review later." + ), + comments: z + .array(reviewCommentSchema) + .optional() + .describe("An array specifying the location, destination, and contents of draft review comments."), +}); + +export type CreateReviewInput = z.infer; + +// Implementation of the tool function +async function createPullRequestReviewFunction( + input: CreateReviewInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + // Extract path parameters and request payload fields from the input + const { owner, repo, pull_number, commit_id, body, event, comments } = input; + + // Build the request body payload; skip undefined values + const payload: Record = {}; + if (commit_id !== undefined) payload.commit_id = commit_id; + if (body !== undefined) payload.body = body; + if (event !== undefined) payload.event = event; + if (comments !== undefined) payload.comments = comments; + + const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pull_number}/reviews`; + + const response = await ofetch(url, { + method: "POST", + headers: { + // Set Accept header to get raw markdown response by default (custom media type) + Accept: "application/vnd.github-commitcomment.raw+json", + "Content-Type": "application/json", + Authorization: `Bearer ${metadata.auth.apiKey}`, + }, + // Send the payload; if no review body parameters are provided an empty object is sent. + body: payload, + // Parse the response as text + parseResponse: (text) => text, + }); + + return ok(response); + } catch (error) { + return err( + error instanceof Error + ? error + : new Error("Failed to create review for pull request") + ); + } +} + +// Export the tool object for the Toolsette framework +export const create_pull_request_review: ToolsetteTool = { + name: "create_pull_request_review", + description: + "Creates a review on a specified pull request. This endpoint triggers notifications and supports creating pending reviews with inline comments.", + parameters: createReviewSchema, + function: createPullRequestReviewFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/pulls-get.test.ts b/packages/github/tools/pulls-get.test.ts new file mode 100644 index 0000000..b61615e --- /dev/null +++ b/packages/github/tools/pulls-get.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { getPullRequest } from "./pull_request-get"; + +const getPullRequestFunction = getPullRequest.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +const GITHUB_PR_OWNER = process.env.GITHUB_PR_OWNER || "octocat"; +const GITHUB_PR_REPO = process.env.GITHUB_PR_REPO || "Hello-World"; +const GITHUB_PR_NUMBER = process.env.GITHUB_PR_NUMBER + ? parseInt(process.env.GITHUB_PR_NUMBER, 10) + : 1347; + +describe("getPullRequestFunction Integration Tests", () => { + const validAuth = { + type: "Bearer" as const, + apiKey: GITHUB_TOKEN, + }; + + it("should fetch pull request with valid parameters", async () => { + const result = await getPullRequestFunction( + { + owner: GITHUB_PR_OWNER, + repo: GITHUB_PR_REPO, + pull_number: GITHUB_PR_NUMBER, + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle non-existent pull request", async () => { + const result = await getPullRequestFunction( + { + owner: GITHUB_PR_OWNER, + repo: GITHUB_PR_REPO, + pull_number: 9999999, + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const result = await getPullRequestFunction( + { + owner: GITHUB_PR_OWNER, + repo: GITHUB_PR_REPO, + pull_number: GITHUB_PR_NUMBER, + }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle boundary pull request number 0", async () => { + const result = await getPullRequestFunction( + { + owner: GITHUB_PR_OWNER, + repo: GITHUB_PR_REPO, + pull_number: 0, + }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/pulls-get.ts b/packages/github/tools/pulls-get.ts new file mode 100644 index 0000000..46b74df --- /dev/null +++ b/packages/github/tools/pulls-get.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// Define the input schema for getting a pull request +const getPullRequestSchema = z.object({ + owner: z + .string() + .describe( + "The account owner of the repository. The name is not case sensitive." + ), + repo: z + .string() + .describe( + "The name of the repository without the `.git` extension. The name is not case sensitive." + ), + pull_number: z + .number() + .int() + .describe("The number that identifies the pull request."), +}); + +type GetPullRequestInput = z.infer; + +/** + * Gets a pull request from GitHub by its number. + * + * Draft pull requests are available in public repositories with GitHub Free and GitHub Free for organizations, + * GitHub Pro, and legacy per-repository billing plans, and in public and private repositories with GitHub Team + * and GitHub Enterprise Cloud. This endpoint returns detailed information about a pull request. + * + * Pass the appropriate media type to fetch diff and patch formats. + */ +async function getPullRequestFunction( + input: GetPullRequestInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/pulls/${input.pull_number}`; + const headers: Record = { + "Accept": "application/vnd.github.v3+json", + }; + + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + const res = await ofetch(url, { + method: "GET", + headers, + parseResponse: (responseText) => responseText, + }); + + return ok(res); + } catch (error) { + return err( + error instanceof Error ? error : new Error("Failed to fetch pull request") + ); + } +} + +export const getPullRequest: ToolsetteTool = { + name: "get_pull_request", + description: + "Get a pull request. Draft pull requests are available in public repositories with GitHub Free and GitHub Free for organizations, GitHub Pro, and legacy per-repository billing plans, and in public and private repositories with GitHub Team and GitHub Enterprise Cloud. Lists details of a pull request by providing its number. Pass the appropriate media type to fetch diff and patch formats.", + parameters: getPullRequestSchema, + function: getPullRequestFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/pulls-list.test.ts b/packages/github/tools/pulls-list.test.ts new file mode 100644 index 0000000..b3ec638 --- /dev/null +++ b/packages/github/tools/pulls-list.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { listPullRequests } from "./pull_requests-list"; + +const listPullRequestsFunction = listPullRequests.function; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +describe("listPullRequestsFunction Integration Tests", () => { + const auth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + + it("should fetch pull requests with default parameters", async () => { + const result = await listPullRequestsFunction( + { + owner: "octocat", + repo: "Hello-World", + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch pull requests with custom parameters", async () => { + const result = await listPullRequestsFunction( + { + owner: "octocat", + repo: "Hello-World", + state: "closed", + sort: "updated", + direction: "asc", + per_page: 5, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch pull requests with maximum per_page parameter", async () => { + const result = await listPullRequestsFunction( + { + owner: "octocat", + repo: "Hello-World", + state: "all", + per_page: 100, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should filter pull requests by head parameter", async () => { + const result = await listPullRequestsFunction( + { + owner: "octocat", + repo: "Hello-World", + head: "octocat:master", + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should filter pull requests by base parameter", async () => { + const result = await listPullRequestsFunction( + { + owner: "octocat", + repo: "Hello-World", + base: "master", + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const result = await listPullRequestsFunction( + { + owner: "octocat", + repo: "Hello-World", + }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error for non-existent repository", async () => { + const result = await listPullRequestsFunction( + { + owner: "nonexistentowner_xyz", + repo: "nonexistentrepo_xyz", + state: "all", + sort: "created", + per_page: 30, + page: 1, + }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/pulls-list.ts b/packages/github/tools/pulls-list.ts new file mode 100644 index 0000000..8b50626 --- /dev/null +++ b/packages/github/tools/pulls-list.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const listPullRequestsSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + state: z + .enum(["open", "closed", "all"]) + .default("open") + .describe("Either `open`, `closed`, or `all` to filter by state."), + head: z + .string() + .optional() + .describe("Filter pulls by head user or head organization and branch name in the format of `user:ref-name` or `organization:ref-name`. For example: `github:new-script-format` or `octocat:test-branch`."), + base: z + .string() + .optional() + .describe("Filter pulls by base branch name. Example: `gh-pages`."), + sort: z + .enum(["created", "updated", "popularity", "long-running"]) + .default("created") + .describe("What to sort results by. `popularity` will sort by the number of comments. `long-running` will sort by date created and will limit the results to pull requests that have been open for more than a month and have had activity within the past month."), + direction: z + .enum(["asc", "desc"]) + .optional() + .describe("The direction of the sort. Default: `desc` when sort is `created` or sort is not specified, otherwise `asc`."), + per_page: z + .number() + .int() + .default(30) + .describe("The number of results per page (max 100). For more information, see \"[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api).\""), + page: z + .number() + .int() + .default(1) + .describe("The page number of the results to fetch. For more information, see \"[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api).\"") +}); + +type ListPullRequestsInput = z.infer; + +async function listPullRequestsFunction( + input: ListPullRequestsInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + // Build the query parameters object filtering out optional keys if not provided. + const query: Record = { + state: input.state, + sort: input.sort, + per_page: input.per_page, + page: input.page + }; + if (input.head) { + query.head = input.head; + } + if (input.base) { + query.base = input.base; + } + if (input.direction) { + query.direction = input.direction; + } + + // Construct the endpoint URL using path parameters + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/pulls`; + + // Prepare headers. Set the Accept header to receive JSON. + const headers: Record = { + Accept: "application/vnd.github.v3+json" + }; + if (metadata.auth.apiKey) { + headers.Authorization = `Bearer ${metadata.auth.apiKey}`; + } + + // Perform the GET request + const res = await ofetch(url, { + method: "GET", + query, + headers, + // For simplicity, return the raw text response. + parseResponse: (text) => text + }); + + return ok(res); + } catch (error) { + return err( + error instanceof Error ? error : new Error("Failed to list pull requests") + ); + } +} + +export const listPullRequests: ToolsetteTool = { + name: "list_pull_requests", + description: + "Lists pull requests in a specified repository.\n\nDraft pull requests are available in public repositories with GitHub Free and GitHub Free for organizations, GitHub Pro, and legacy per-repository billing plans, and in public and private repositories with GitHub Team and GitHub Enterprise Cloud. For more information, see GitHub's products in the GitHub Help documentation.\n\nThis endpoint supports the following custom media types:\n\n- application/vnd.github.raw+json: Returns the raw markdown body. Response will include `body`.\n- application/vnd.github.text+json: Returns a text only representation of the markdown body. Response will include `body_text`.\n- application/vnd.github.html+json: Returns HTML rendered from the body's markdown. Response will include `body_html`.\n- application/vnd.github.full+json: Returns raw, text, and HTML representations. Response will include `body`, `body_text`, and `body_html`.", + parameters: listPullRequestsSchema, + function: listPullRequestsFunction +}; \ No newline at end of file diff --git a/packages/github/tools/repos-create-fork.test.ts b/packages/github/tools/repos-create-fork.test.ts new file mode 100644 index 0000000..6398591 --- /dev/null +++ b/packages/github/tools/repos-create-fork.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { createFork } from "./fork-create"; + +const createForkFunction = createFork.function; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +const auth = { + type: "Bearer" as const, + apiKey: GITHUB_TOKEN, +}; + +describe("createForkFunction Integration Tests", () => { + it("should create a fork with required parameters only", async () => { + const result = await createForkFunction( + { owner: "octocat", repo: "Hello-World" }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should create a fork with a new name", async () => { + const uniqueName = "test-fork-" + Date.now(); + const result = await createForkFunction( + { owner: "octocat", repo: "Hello-World", name: uniqueName }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should create a fork with default_branch_only flag true", async () => { + const result = await createForkFunction( + { owner: "octocat", repo: "Hello-World", default_branch_only: true }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail with invalid auth token", async () => { + const result = await createForkFunction( + { owner: "octocat", repo: "Hello-World" }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fail with non-existing repository", async () => { + const result = await createForkFunction( + { owner: "octocat", repo: "nonexistent-repo-abc123" }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + if (process.env.GITHUB_ORG) { + it("should create a fork in an organization", async () => { + const result = await createForkFunction( + { owner: "octocat", repo: "Hello-World", organization: process.env.GITHUB_ORG }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + } else { + it.skip("should create a fork in an organization", async () => {}); + } +}); \ No newline at end of file diff --git a/packages/github/tools/repos-create-fork.ts b/packages/github/tools/repos-create-fork.ts new file mode 100644 index 0000000..34e62e8 --- /dev/null +++ b/packages/github/tools/repos-create-fork.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +/** + * Schema for creating a fork. + * + * Parameters: + * - owner (required): The account owner of the repository. The name is not case sensitive. + * - repo (required): The name of the repository without the `.git` extension. The name is not case sensitive. + * + * Request body (all optional): + * - organization: Optional parameter to specify the organization name if forking into an organization. + * - name: When forking from an existing repository, a new name for the fork. + * - default_branch_only: When forking from an existing repository, fork with only the default branch. + */ +const createForkSchema = z.object({ + owner: z.string().describe("The account owner of the repository. The name is not case sensitive."), + repo: z.string().describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + organization: z.string().optional().describe("Optional parameter to specify the organization name if forking into an organization."), + name: z.string().optional().describe("When forking from an existing repository, a new name for the fork."), + default_branch_only: z.boolean().optional().describe("When forking from an existing repository, fork with only the default branch."), +}); + +type CreateForkInput = z.infer; + +async function createForkFunction( + input: CreateForkInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/forks`; + + // Build the request body only if at least one property is provided. + const requestBody: { organization?: string; name?: string; default_branch_only?: boolean } = {}; + if (input.organization !== undefined) requestBody.organization = input.organization; + if (input.name !== undefined) requestBody.name = input.name; + if (input.default_branch_only !== undefined) requestBody.default_branch_only = input.default_branch_only; + + const headers: Record = { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${metadata.auth.apiKey}`, + }; + + const options: Record = { + method: "POST", + headers, + // Only include body if there is at least one field + ...(Object.keys(requestBody).length > 0 && { body: requestBody }), + // Ensure that ofetch returns the response as parsed JSON. + parseResponse: (data: string) => JSON.parse(data), + }; + + const res = await ofetch(url, options); + return ok(res); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to create fork")); + } +} + +export const createFork: ToolsetteTool = { + name: "create_fork", + description: + "Create a fork for the authenticated user.\n\n> NOTE\n> Forking a repository happens asynchronously. You may have to wait a short period of time before you can access the git objects. If this takes longer than 5 minutes, be sure to contact GitHub Support.\n\n> NOTE\n> Although this endpoint works with GitHub Apps, the GitHub App must be installed on the destination account with access to all repositories and on the source account with access to the source repository.", + parameters: createForkSchema, + function: createForkFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/repos-create-in-org.test.ts b/packages/github/tools/repos-create-in-org.test.ts new file mode 100644 index 0000000..cfd801f --- /dev/null +++ b/packages/github/tools/repos-create-in-org.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { createOrgRepo } from "./org-repo-create"; + +const createOrgRepoFunction = createOrgRepo.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_TEST_ORG = process.env.GITHUB_TEST_ORG; + +if (!GITHUB_TOKEN || !GITHUB_TEST_ORG) { + throw new Error("GITHUB_TOKEN and GITHUB_TEST_ORG environment variables are required to run tests"); +} + +describe("createOrgRepoFunction Integration Tests", () => { + const auth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + + it("should create repository with minimal required parameters", async () => { + const repoName = `test-repo-minimal-${Date.now()}`; + const input = { + org: GITHUB_TEST_ORG, + name: repoName, + }; + const result = await createOrgRepoFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should create repository with comprehensive optional parameters", async () => { + const repoName = `test-repo-full-${Date.now()}`; + const input = { + org: GITHUB_TEST_ORG, + name: repoName, + description: "A comprehensive test repository", + homepage: "https://example.com", + private: true, + visibility: "private", + has_issues: false, + has_projects: false, + has_wiki: false, + has_downloads: false, + is_template: true, + auto_init: true, + gitignore_template: "Node", + license_template: "mit", + allow_squash_merge: false, + allow_merge_commit: false, + allow_rebase_merge: false, + allow_auto_merge: true, + delete_branch_on_merge: true, + use_squash_pr_title_as_default: true, + squash_merge_commit_title: "PR_TITLE", + squash_merge_commit_message: "PR_BODY", + merge_commit_title: "PR_TITLE", + merge_commit_message: "PR_BODY", + custom_properties: { testing: "comprehensive", number: 42 }, + }; + const result = await createOrgRepoFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should error when creating a repository that already exists", async () => { + const repoName = `test-repo-duplicate-${Date.now()}`; + const input = { + org: GITHUB_TEST_ORG, + name: repoName, + }; + const firstResult = await createOrgRepoFunction(input, { auth }); + const secondResult = await createOrgRepoFunction(input, { auth }); + expect(firstResult).toMatchInlineSnapshot(); + expect(secondResult).toMatchInlineSnapshot(); + }); + + it("should handle invalid authentication token", async () => { + const repoName = `test-repo-invalid-auth-${Date.now()}`; + const input = { + org: GITHUB_TEST_ORG, + name: repoName, + }; + const invalidAuth = { type: "Bearer", apiKey: "invalid-token" }; + const result = await createOrgRepoFunction(input, { auth: invalidAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle error for non-existent organization", async () => { + const repoName = `test-repo-nonexistent-org-${Date.now()}`; + const bogusOrg = `nonexistent-org-${Date.now()}`; + const input = { + org: bogusOrg, + name: repoName, + }; + const result = await createOrgRepoFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should succeed when optional string parameters are empty", async () => { + const repoName = `test-repo-empty-string-${Date.now()}`; + const input = { + org: GITHUB_TEST_ORG, + name: repoName, + description: "", + homepage: "", + }; + const result = await createOrgRepoFunction(input, { auth }); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/repos-create-in-org.ts b/packages/github/tools/repos-create-in-org.ts new file mode 100644 index 0000000..57c1cd7 --- /dev/null +++ b/packages/github/tools/repos-create-in-org.ts @@ -0,0 +1,160 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const createOrgRepoSchema = z.object({ + // Path parameter + org: z.string().describe("The organization name. The name is not case sensitive."), + // Request body parameters + name: z.string().describe("The name of the repository."), + description: z.string().optional().describe("A short description of the repository."), + homepage: z.string().optional().describe("A URL with more information about the repository."), + private: z.boolean().optional().default(false).describe("Whether the repository is private."), + visibility: z.enum(["public", "private"]).optional().describe("The visibility of the repository."), + has_issues: z + .boolean() + .optional() + .default(true) + .describe("Either `true` to enable issues for this repository or `false` to disable them."), + has_projects: z + .boolean() + .optional() + .default(true) + .describe("Either `true` to enable projects for this repository or `false` to disable them. Note: if you're creating a repository in an organization that has disabled repository projects, the default is `false`, and if you pass `true`, the API returns an error."), + has_wiki: z + .boolean() + .optional() + .default(true) + .describe("Either `true` to enable the wiki for this repository or `false` to disable it."), + has_downloads: z + .boolean() + .optional() + .default(true) + .describe("Whether downloads are enabled."), + is_template: z + .boolean() + .optional() + .default(false) + .describe("Either `true` to make this repo available as a template repository or `false` to prevent it."), + team_id: z + .number() + .int() + .optional() + .describe("The id of the team that will be granted access to this repository. This is only valid when creating a repository in an organization."), + auto_init: z + .boolean() + .optional() + .default(false) + .describe("Pass `true` to create an initial commit with empty README."), + gitignore_template: z + .string() + .optional() + .describe("Desired language or platform .gitignore template to apply. Use the name of the template without the extension. For example, 'Haskell'."), + license_template: z + .string() + .optional() + .describe("Choose an open source license template that best suits your needs, and then use the license keyword as the license_template string. For example, 'mit' or 'mpl-2.0'."), + allow_squash_merge: z + .boolean() + .optional() + .default(true) + .describe("Either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging."), + allow_merge_commit: z + .boolean() + .optional() + .default(true) + .describe("Either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits."), + allow_rebase_merge: z + .boolean() + .optional() + .default(true) + .describe("Either `true` to allow rebase-merging pull requests, or `false` to prevent rebase-merging."), + allow_auto_merge: z + .boolean() + .optional() + .default(false) + .describe("Either `true` to allow auto-merge on pull requests, or `false` to disallow auto-merge."), + delete_branch_on_merge: z + .boolean() + .optional() + .default(false) + .describe( + "Either `true` to allow automatically deleting head branches when pull requests are merged, or `false` to prevent automatic deletion. The authenticated user must be an organization owner to set this property to `true`." + ), + use_squash_pr_title_as_default: z + .boolean() + .optional() + .default(false) + .describe("Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. (Deprecated: please use `squash_merge_commit_title` instead)"), + squash_merge_commit_title: z + .enum(["PR_TITLE", "COMMIT_OR_PR_TITLE"]) + .optional() + .describe( + "Default value for a squash merge commit title. Options: 'PR_TITLE' defaults to the pull request's title; 'COMMIT_OR_PR_TITLE' defaults to the commit's title (if only one commit) or the pull request's title (when more than one commit)." + ), + squash_merge_commit_message: z + .enum(["PR_BODY", "COMMIT_MESSAGES", "BLANK"]) + .optional() + .describe( + "Default value for a squash merge commit message. Options: 'PR_BODY' to default to the pull request's body; 'COMMIT_MESSAGES' to default to the branch's commit messages; 'BLANK' for a blank commit message." + ), + merge_commit_title: z + .enum(["PR_TITLE", "MERGE_MESSAGE"]) + .optional() + .describe( + "Default value for a merge commit title. Options: 'PR_TITLE' to default to the pull request's title; 'MERGE_MESSAGE' to default to the classic merge message." + ), + merge_commit_message: z + .enum(["PR_BODY", "PR_TITLE", "BLANK"]) + .optional() + .describe( + "Default value for a merge commit message. Options: 'PR_TITLE' to default to the pull request's title; 'PR_BODY' to default to the pull request's body; 'BLANK' for a blank commit message." + ), + custom_properties: z + .record(z.any()) + .optional() + .describe("The custom properties for the new repository. The keys are the custom property names, and the values are the corresponding custom property values.") +}); + +type CreateOrgRepoInput = z.infer; + +async function createOrgRepoFunction( + input: CreateOrgRepoInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + // Separate the organization from the request body properties + const { org, ...body } = input; + const url = `https://api.github.com/orgs/${encodeURIComponent(org)}/repos`; + + const headers: Record = { + Accept: "application/vnd.github.v3+json", + }; + + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + // Create the repository by sending a POST request. + const response = await ofetch(url, { + method: "POST", + body, + headers, + // Return the response as a JSON string + parseResponse: (res) => JSON.stringify(res), + }); + + return ok(response); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to create organization repository")); + } +} + +export const createOrgRepo: ToolsetteTool = { + name: "create_org_repo", + description: + "Creates a new repository in the specified organization. The authenticated user must be a member of the organization.\n\nOAuth app tokens and personal access tokens (classic) need the `public_repo` or `repo` scope to create a public repository, and `repo` scope to create a private repository.", + parameters: createOrgRepoSchema, + function: createOrgRepoFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/repos-delete.test.ts b/packages/github/tools/repos-delete.test.ts new file mode 100644 index 0000000..745c79c --- /dev/null +++ b/packages/github/tools/repos-delete.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { ofetch } from "ofetch"; +import { deleteRepo } from "./repos-delete"; + +const deleteRepoFunction = deleteRepo.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_USERNAME = process.env.GITHUB_USERNAME; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +if (!GITHUB_USERNAME) { + throw new Error("GITHUB_USERNAME environment variable is required to run tests"); +} + +async function createTestRepo(repoName: string, auth: { type: "Bearer"; apiKey: string }) { + await ofetch("https://api.github.com/user/repos", { + method: "POST", + headers: { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${auth.apiKey}`, + }, + body: { + name: repoName, + auto_init: false, + }, + }); +} + +describe("deleteRepoFunction Integration Tests", () => { + const auth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + + it("should successfully delete an existing repository", async () => { + const repoName = `toolsette-delete-test-${Date.now()}`; + await createTestRepo(repoName, auth); + const result = await deleteRepoFunction({ owner: GITHUB_USERNAME, repo: repoName }, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle repository not found", async () => { + const repoName = `non-existent-repo-${Date.now()}`; + const result = await deleteRepoFunction({ owner: GITHUB_USERNAME, repo: repoName }, { auth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const repoName = `non-existent-repo-invalid-auth-${Date.now()}`; + const result = await deleteRepoFunction( + { owner: GITHUB_USERNAME, repo: repoName }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle empty parameters", async () => { + const result = await deleteRepoFunction({ owner: "", repo: "" }, { auth }); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/repos-delete.ts b/packages/github/tools/repos-delete.ts new file mode 100644 index 0000000..ffadec6 --- /dev/null +++ b/packages/github/tools/repos-delete.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const deleteRepoSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive."), +}); + +type DeleteRepoInput = z.infer; + +async function deleteRepoFunction( + input: DeleteRepoInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}`; + + const headers: Record = { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${metadata.auth.apiKey}`, + }; + + // Send the DELETE request to GitHub API. + await ofetch(url, { + method: "DELETE", + headers, + // Parse response as text. For a 204 No Content response, this will be an empty string. + parseResponse: (response) => response.text(), + }); + + return ok("Repository deleted successfully"); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to delete repository")); + } +} + +export const deleteRepo: ToolsetteTool = { + name: "delete_repo", + description: + "Deleting a repository requires admin access.\n\nIf an organization owner has configured the organization to prevent members from deleting organization-owned repositories, you will get a 403 Forbidden response.\n\nOAuth app tokens and personal access tokens (classic) need the delete_repo scope to use this endpoint.", + parameters: deleteRepoSchema, + function: deleteRepoFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/repos-get-content.test.ts b/packages/github/tools/repos-get-content.test.ts new file mode 100644 index 0000000..dad9556 --- /dev/null +++ b/packages/github/tools/repos-get-content.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { getRepositoryContent } from "./get_repository_content"; + +const getRepoContentFunction = getRepositoryContent.function; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +describe("getRepositoryContentFunction Integration Tests", () => { + const validAuth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + + it("should fetch file content from a valid repository", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + path: "README", + }; + const result = await getRepoContentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch repository root directory when path is empty", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + path: "", + }; + const result = await getRepoContentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch file content using ref parameter", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + path: "README", + ref: "master", + }; + const result = await getRepoContentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid authentication token", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + path: "README", + }; + const invalidAuth = { type: "Bearer", apiKey: "invalid-token" }; + const result = await getRepoContentFunction(input, { auth: invalidAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error for non-existent repository", async () => { + const input = { + owner: "nonexistentowner", + repo: "nonexistentrepo", + path: "README", + }; + const result = await getRepoContentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error for non-existent file or path", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + path: "nonexistent-path", + }; + const result = await getRepoContentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); + + it("should properly encode multi-segment path with special characters", async () => { + const input = { + owner: "octocat", + repo: "Hello-World", + path: "some folder/another folder/file with spaces.txt", + }; + const result = await getRepoContentFunction(input, { auth: validAuth }); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/repos-get-content.ts b/packages/github/tools/repos-get-content.ts new file mode 100644 index 0000000..356ffc3 --- /dev/null +++ b/packages/github/tools/repos-get-content.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// Input schema for the "Get repository content" endpoint. +// Parameters: +// • owner (required string): The account owner of the repository. The name is not case sensitive. +// • repo (required string): The name of the repository without the `.git` extension. The name is not case sensitive. +// • path (required string): Specifies the file path or directory in the repository. Supports multi‐segment paths. +// • ref (optional string): The name of the commit/branch/tag. Defaults to the repository’s default branch. +const getRepositoryContentSchema = z.object({ + owner: z.string().describe("The account owner of the repository. The name is not case sensitive."), + repo: z.string().describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + path: z.string().describe("Specifies the file path or directory in the repository. Supports multi-segment paths."), + ref: z.string().optional().describe("The name of the commit/branch/tag. Default: the repository’s default branch."), +}); + +type GetRepositoryContentInput = z.infer; + +// The tool function that calls the GitHub API for repository content. +// It handles authentication via metadata, constructs the URL from path parameters, +// adds an optional query parameter if provided, and returns the raw response as a string. +async function getRepositoryContentFunction( + input: GetRepositoryContentInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + // Encode each segment of the path to preserve any multi-segment structure. + const encodedPath = input.path.split('/').map(encodeURIComponent).join('/'); + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/contents/${encodedPath}`; + + // Build query: include "ref" only if provided. + const query: { ref?: string } = {}; + if (input.ref) { + query.ref = input.ref; + } + + // Set up headers with the custom media type and Bearer token for authentication. + const headers: Record = { + "Accept": "application/vnd.github.object+json", + }; + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + // Make the API call using ofetch. The response is returned as a text string. + const response = await ofetch(url, { + method: "GET", + query, + headers, + parseResponse: (text) => text, + }); + + return ok(response); + } catch (error) { + return err( + error instanceof Error + ? error + : new Error("Failed to get repository content") + ); + } +} + +// Export the tool object. The object name is in camelCase, +// and the "name" property uses snake_case as per the guidelines. +export const getRepositoryContent: ToolsetteTool = { + name: "get_repository_content", + description: + "Gets the contents of a file or directory in a repository. Specify the file path or directory with the `path` parameter. If you omit the `path` parameter, you will receive the contents of the repository's root directory. This endpoint supports custom media types: application/vnd.github.raw+json, application/vnd.github.html+json, and application/vnd.github.object+json.", + parameters: getRepositoryContentSchema, + function: getRepositoryContentFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/repos-get.test.ts b/packages/github/tools/repos-get.test.ts new file mode 100644 index 0000000..1db58db --- /dev/null +++ b/packages/github/tools/repos-get.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { getRepository } from "./get_repository"; + +const getRepositoryFunction = getRepository.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +describe("getRepositoryFunction Integration Tests", () => { + const auth = { type: "Bearer" as const, apiKey: GITHUB_TOKEN }; + + it("should fetch repository data for a valid repository", async () => { + const result = await getRepositoryFunction( + { owner: "octocat", repo: "Hello-World" }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch repository data using uppercase parameters", async () => { + const result = await getRepositoryFunction( + { owner: "OCTOCAT", repo: "HELLO-WORLD" }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error for a non-existent repository", async () => { + const result = await getRepositoryFunction( + { owner: "octocat", repo: "nonexistentrepo-abcxyz" }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid authentication token", async () => { + const invalidAuth = { type: "Bearer" as const, apiKey: "invalid-token" }; + const result = await getRepositoryFunction( + { owner: "octocat", repo: "Hello-World" }, + { auth: invalidAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle empty repository name", async () => { + const result = await getRepositoryFunction( + { owner: "octocat", repo: "" }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle empty owner", async () => { + const result = await getRepositoryFunction( + { owner: "", repo: "Hello-World" }, + { auth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/repos-get.ts b/packages/github/tools/repos-get.ts new file mode 100644 index 0000000..f24e595 --- /dev/null +++ b/packages/github/tools/repos-get.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// Input parameters schema for the GET a repository endpoint +const getRepositorySchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe("The name of the repository without the `.git` extension. The name is not case sensitive.") +}); + +type GetRepositoryInput = z.infer; + +async function getRepositoryFunction( + input: GetRepositoryInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}`; + const headers: Record = { + "Accept": "application/vnd.github.v3+json" + }; + + // Handle authentication using the provided API key + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + const res = await ofetch(url, { + method: "GET", + headers, + // Parse the API response as JSON + parseResponse: (text) => JSON.parse(text) + }); + + return ok(res); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to get repository")); + } +} + +export const getRepository: ToolsetteTool = { + name: "get_repository", + description: + "Get a repository. The `parent` and `source` objects are present when the repository is a fork. `parent` is the repository this repository was forked from, `source` is the ultimate source for the network. In order to see the `security_and_analysis` block for a repository you must have admin permissions for the repository or be an owner or security manager for the organization that owns the repository.", + parameters: getRepositorySchema, + function: getRepositoryFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/repos-list-forks.test.ts b/packages/github/tools/repos-list-forks.test.ts new file mode 100644 index 0000000..faef179 --- /dev/null +++ b/packages/github/tools/repos-list-forks.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { listForks } from "./gists-forks"; + +const listForksFunction = listForks.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} + +describe("listForksFunction Integration Tests", () => { + const validAuth = { type: "Bearer", apiKey: GITHUB_TOKEN }; + + it("should fetch forks with default parameters", async () => { + const result = await listForksFunction( + { owner: "octocat", repo: "Hello-World" }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch forks with pagination parameters", async () => { + const result = await listForksFunction( + { owner: "octocat", repo: "Hello-World", per_page: 5, page: 1 }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should fetch forks with sort 'oldest'", async () => { + const result = await listForksFunction( + { owner: "octocat", repo: "Hello-World", sort: "oldest", per_page: 10, page: 1 }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle non-existent repository error", async () => { + const result = await listForksFunction( + { owner: "octocat", repo: "nonexistentrepo", per_page: 5, page: 1 }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid auth token", async () => { + const invalidAuth = { type: "Bearer", apiKey: "invalid-token" }; + const result = await listForksFunction( + { owner: "octocat", repo: "Hello-World" }, + { auth: invalidAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle minimum per_page boundary", async () => { + const result = await listForksFunction( + { owner: "octocat", repo: "Hello-World", per_page: 1, page: 1 }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle maximum per_page boundary", async () => { + const result = await listForksFunction( + { owner: "octocat", repo: "Hello-World", per_page: 100, page: 1 }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return empty result for high page number", async () => { + const result = await listForksFunction( + { owner: "octocat", repo: "Hello-World", page: 9999 }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid per_page less than minimum", async () => { + const result = await listForksFunction( + { owner: "octocat", repo: "Hello-World", per_page: 0, page: 1 }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid per_page greater than maximum", async () => { + const result = await listForksFunction( + { owner: "octocat", repo: "Hello-World", per_page: 101, page: 1 }, + { auth: validAuth } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/github/tools/repos-list-forks.ts b/packages/github/tools/repos-list-forks.ts new file mode 100644 index 0000000..642b587 --- /dev/null +++ b/packages/github/tools/repos-list-forks.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +const listForksSchema = z.object({ + owner: z + .string() + .describe("The account owner of the repository. The name is not case sensitive."), + repo: z + .string() + .describe( + "The name of the repository without the `.git` extension. The name is not case sensitive." + ), + sort: z + .enum(["newest", "oldest", "stargazers", "watchers"]) + .optional() + .default("newest") + .describe("The sort order. `stargazers` will sort by star count."), + per_page: z + .number() + .int() + .min(1) + .max(100) + .optional() + .default(30) + .describe( + 'The number of results per page (max 100). For more information, see "[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api)."' + ), + page: z + .number() + .int() + .positive() + .optional() + .default(1) + .describe( + 'The page number of the results to fetch. For more information, see "[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api)."' + ), +}); + +type ListForksInput = z.infer; + +async function listForksFunction( + input: ListForksInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + const url = `https://api.github.com/repos/${input.owner}/${input.repo}/forks`; + + const headers: Record = { + Accept: "application/vnd.github.v3+json", + }; + + if (metadata.auth.apiKey) { + headers["Authorization"] = `Bearer ${metadata.auth.apiKey}`; + } + + const query = { + sort: input.sort, + per_page: input.per_page, + page: input.page, + }; + + const response = await ofetch(url, { + method: "GET", + headers, + query, + // Return the raw text response as in the example implementation. + parseResponse: (text: string) => text, + }); + + return ok(response); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to list forks")); + } +} + +export const listForks: ToolsetteTool = { + name: "list_forks", + description: "List forks", + parameters: listForksSchema, + function: listForksFunction, +}; \ No newline at end of file diff --git a/packages/github/tools/repos-update.test.ts b/packages/github/tools/repos-update.test.ts new file mode 100644 index 0000000..eeedf54 --- /dev/null +++ b/packages/github/tools/repos-update.test.ts @@ -0,0 +1,121 @@ +```typescript +import { describe, it, expect } from "vitest"; +import { updateRepo } from "./gists-update"; + +const updateRepoFunction = updateRepo.function; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_OWNER = process.env.GITHUB_OWNER; +const GITHUB_REPO = process.env.GITHUB_REPO; + +if (!GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN environment variable is required to run tests"); +} +if (!GITHUB_OWNER) { + throw new Error("GITHUB_OWNER environment variable is required to run tests"); +} +if (!GITHUB_REPO) { + throw new Error("GITHUB_REPO environment variable is required to run tests"); +} + +describe("updateRepoFunction Integration Tests", () => { + it("should update repository description successfully", async () => { + const result = await updateRepoFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + description: "Integration test update: Test Description 1", + }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should update repository with multiple fields", async () => { + const result = await updateRepoFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + description: "Integration test update: Multi-field update", + homepage: "https://example.com", + }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should update repository with no changes when only required parameters are provided", async () => { + const result = await updateRepoFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle invalid authentication token", async () => { + const result = await updateRepoFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + description: "Invalid auth test", + }, + { auth: { type: "Bearer", apiKey: "invalid-token" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should handle non-existent repository error", async () => { + const result = await updateRepoFunction( + { + owner: GITHUB_OWNER, + repo: "non-existent-repo-integration-test", + }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should return error when API key is missing", async () => { + const result = await updateRepoFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + description: "Missing API key test", + }, + { auth: { type: "Bearer", apiKey: "" } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should update repository with security_and_analysis set to null", async () => { + const result = await updateRepoFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + security_and_analysis: null, + description: "Integration test update: security_and_analysis null", + }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); + + it("should update repository boolean flags", async () => { + const result = await updateRepoFunction( + { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + has_issues: false, + allow_auto_merge: true, + delete_branch_on_merge: true, + description: "Integration test update: boolean flags update", + }, + { auth: { type: "Bearer", apiKey: GITHUB_TOKEN } } + ); + expect(result).toMatchInlineSnapshot(); + }); +}); +``` \ No newline at end of file diff --git a/packages/github/tools/repos-update.ts b/packages/github/tools/repos-update.ts new file mode 100644 index 0000000..b1d0a8a --- /dev/null +++ b/packages/github/tools/repos-update.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; +import { ofetch } from "ofetch"; +import { Result, ok, err } from "neverthrow"; +import { ToolsetteTool } from "@toolsette/utils"; + +// Input schema combining the path parameters and the (optional) request body properties +const updateRepoSchema = z.object({ + // Path parameters + owner: z.string().describe("The account owner of the repository. The name is not case sensitive."), + repo: z.string().describe("The name of the repository without the `.git` extension. The name is not case sensitive."), + // Request body properties (all optional because the request body is not required) + name: z.string().optional().describe("The name of the repository."), + description: z.string().optional().describe("A short description of the repository."), + homepage: z.string().optional().describe("A URL with more information about the repository."), + private: z.boolean().optional().default(false).describe("Either `true` to make the repository private or `false` to make it public. Default: `false`. Note: You will get a 422 error if the organization restricts changing repository visibility."), + visibility: z.enum(["public", "private"]).optional().describe("The visibility of the repository."), + security_and_analysis: z.union([ + z.object({ + advanced_security: z + .object({ + status: z + .enum(["enabled", "disabled"]) + .optional() + .describe("Use the `status` property to enable or disable GitHub Advanced Security for this repository. Can be `enabled` or `disabled`."), + }) + .optional(), + secret_scanning: z + .object({ + status: z.enum(["enabled", "disabled"]).optional(), + }) + .optional(), + secret_scanning_push_protection: z + .object({ + status: z.enum(["enabled", "disabled"]).optional(), + }) + .optional(), + secret_scanning_ai_detection: z + .object({ + status: z.enum(["enabled", "disabled"]).optional(), + }) + .optional(), + secret_scanning_non_provider_patterns: z + .object({ + status: z.enum(["enabled", "disabled"]).optional(), + }) + .optional(), + }), + z.null() + ]) + .optional() + .describe("Specify which security and analysis features to enable or disable for the repository."), + has_issues: z.boolean().optional().default(true).describe("Either `true` to enable issues for this repository or `false` to disable them."), + has_projects: z.boolean().optional().default(true).describe("Either `true` to enable projects for this repository or `false` to disable them. Note: If you're creating a repository in an organization that has disabled repository projects, the default is `false`, and if you pass `true`, the API returns an error."), + has_wiki: z.boolean().optional().default(true).describe("Either `true` to enable the wiki for this repository or `false` to disable it."), + is_template: z.boolean().optional().default(false).describe("Either `true` to make this repo available as a template repository or `false` to prevent it."), + default_branch: z.string().optional().describe("Updates the default branch for this repository."), + allow_squash_merge: z.boolean().optional().default(true).describe("Either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging."), + allow_merge_commit: z.boolean().optional().default(true).describe("Either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits."), + allow_rebase_merge: z.boolean().optional().default(true).describe("Either `true` to allow rebase-merging pull requests, or `false` to prevent rebase-merging."), + allow_auto_merge: z.boolean().optional().default(false).describe("Either `true` to allow auto-merge on pull requests, or `false` to disallow auto-merge."), + delete_branch_on_merge: z.boolean().optional().default(false).describe("Either `true` to allow automatically deleting head branches when pull requests are merged, or `false` to prevent automatic deletion."), + allow_update_branch: z.boolean().optional().default(false).describe("Either `true` to always allow a pull request head branch that is behind its base branch to be updated even if it is not required to be up to date before merging, or false otherwise."), + use_squash_pr_title_as_default: z.boolean().optional().default(false).describe("Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. This property is closing down. Please use `squash_merge_commit_title` instead."), + squash_merge_commit_title: z + .enum(["PR_TITLE", "COMMIT_OR_PR_TITLE"]) + .optional() + .describe( + "The default value for a squash merge commit title. 'PR_TITLE' defaults to the pull request's title; 'COMMIT_OR_PR_TITLE' defaults to the commit's title (if only one commit) or the pull request's title (when more than one commit)." + ), + squash_merge_commit_message: z + .enum(["PR_BODY", "COMMIT_MESSAGES", "BLANK"]) + .optional() + .describe( + "The default value for a squash merge commit message. 'PR_BODY' defaults to the pull request's body; 'COMMIT_MESSAGES' defaults to the branch's commit messages; 'BLANK' defaults to a blank commit message." + ), + merge_commit_title: z + .enum(["PR_TITLE", "MERGE_MESSAGE"]) + .optional() + .describe( + "The default value for a merge commit title. 'PR_TITLE' defaults to the pull request's title; 'MERGE_MESSAGE' defaults to the classic title for a merge message." + ), + merge_commit_message: z + .enum(["PR_BODY", "PR_TITLE", "BLANK"]) + .optional() + .describe( + "The default value for a merge commit message. 'PR_TITLE' defaults to the pull request's title; 'PR_BODY' defaults to the pull request's body; 'BLANK' defaults to a blank commit message." + ), + archived: z.boolean().optional().default(false).describe("Whether to archive this repository. `false` will unarchive a previously archived repository."), + allow_forking: z.boolean().optional().default(false).describe("Either `true` to allow private forks, or `false` to prevent private forks."), + web_commit_signoff_required: z.boolean().optional().default(false).describe("Either `true` to require contributors to sign off on web-based commits, or `false` to not require contributors to sign off on web-based commits.") +}); + +export type UpdateRepoInput = z.infer; + +async function updateRepoFunction( + input: UpdateRepoInput, + metadata: { auth: { type: "Bearer"; apiKey: string } } +): Promise> { + try { + if (!metadata.auth || !metadata.auth.apiKey) { + return err(new Error("Missing API key")); + } + + // Extract path parameters and the remaining properties for the request body + const { owner, repo, ...body } = input; + + const response = await ofetch(`https://api.github.com/repos/${owner}/${repo}`, { + method: "PATCH", + body, + headers: { + "Authorization": `Bearer ${metadata.auth.apiKey}`, + "Accept": "application/vnd.github+json", + "Content-Type": "application/json" + } + }); + return ok(response); + } catch (error) { + return err(error instanceof Error ? error : new Error("Failed to update repository")); + } +} + +export const updateRepo: ToolsetteTool = { + name: "update_repo", + description: + "Update a repository. Note: To edit a repository's topics, use the Replace all repository topics endpoint. API method documentation: https://docs.github.com/rest/repos/repos#update-a-repository", + parameters: updateRepoSchema, + function: updateRepoFunction +}; \ No newline at end of file diff --git a/packages/github/vitest.config.ts b/packages/github/vitest.config.ts index 02bc193..f12111d 100644 --- a/packages/github/vitest.config.ts +++ b/packages/github/vitest.config.ts @@ -2,7 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["**/*.evals.ts"], testTimeout: 10000, }, });