@esaio/esa-mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.claude/settings.local.json +23 -0
  2. package/.dockerignore +36 -0
  3. package/.envrc +2 -0
  4. package/.github/dependabot.yml +18 -0
  5. package/.github/workflows/docker-publish.yml +120 -0
  6. package/.github/workflows/main.yml +41 -0
  7. package/.node-version +1 -0
  8. package/CLAUDE.md +94 -0
  9. package/Dockerfile +34 -0
  10. package/LICENSE +7 -0
  11. package/README.en.md +139 -0
  12. package/README.md +139 -0
  13. package/bin/index.js +30 -0
  14. package/biome.json +57 -0
  15. package/package.json +48 -0
  16. package/src/__tests__/fixtures/mock-comment.ts +90 -0
  17. package/src/__tests__/fixtures/mock-post.ts +79 -0
  18. package/src/__tests__/index.test.ts +209 -0
  19. package/src/api_client/__tests__/index.test.ts +149 -0
  20. package/src/api_client/__tests__/middleware.test.ts +119 -0
  21. package/src/api_client/__tests__/with-context.test.ts +98 -0
  22. package/src/api_client/index.ts +29 -0
  23. package/src/api_client/middleware.ts +21 -0
  24. package/src/api_client/with-context.ts +26 -0
  25. package/src/config/__tests__/index.test.ts +65 -0
  26. package/src/config/index.ts +20 -0
  27. package/src/context/mcp-context.ts +1 -0
  28. package/src/context/stdio-context.ts +6 -0
  29. package/src/errors/missing-team-name-error.ts +8 -0
  30. package/src/formatters/__tests__/mcp-response.test.ts +106 -0
  31. package/src/formatters/mcp-response.ts +95 -0
  32. package/src/generated/api-types.ts +2691 -0
  33. package/src/i18n/__tests__/index.test.ts +53 -0
  34. package/src/i18n/index.ts +39 -0
  35. package/src/index.ts +47 -0
  36. package/src/locales/en.json +13 -0
  37. package/src/locales/ja.json +13 -0
  38. package/src/prompts/__tests__/index.test.ts +47 -0
  39. package/src/prompts/__tests__/summarize-post.test.ts +291 -0
  40. package/src/prompts/index.ts +21 -0
  41. package/src/prompts/summarize-post.ts +94 -0
  42. package/src/resources/__tests__/index.test.ts +49 -0
  43. package/src/resources/__tests__/recent-posts-list.test.ts +91 -0
  44. package/src/resources/__tests__/recent-posts.test.ts +270 -0
  45. package/src/resources/index.ts +33 -0
  46. package/src/resources/recent-posts-list.ts +22 -0
  47. package/src/resources/recent-posts.ts +45 -0
  48. package/src/schemas/team-name-schema.ts +19 -0
  49. package/src/tools/__tests__/categories.test.ts +226 -0
  50. package/src/tools/__tests__/comments.test.ts +970 -0
  51. package/src/tools/__tests__/helps.test.ts +222 -0
  52. package/src/tools/__tests__/index.test.ts +47 -0
  53. package/src/tools/__tests__/post-actions.test.ts +445 -0
  54. package/src/tools/__tests__/posts.test.ts +917 -0
  55. package/src/tools/__tests__/search.test.ts +339 -0
  56. package/src/tools/__tests__/teams.test.ts +615 -0
  57. package/src/tools/categories.ts +93 -0
  58. package/src/tools/comments.ts +258 -0
  59. package/src/tools/helps.ts +50 -0
  60. package/src/tools/index.ts +324 -0
  61. package/src/tools/post-actions.ts +132 -0
  62. package/src/tools/posts.ts +179 -0
  63. package/src/tools/search.ts +98 -0
  64. package/src/tools/teams.ts +157 -0
  65. package/src/transformers/__tests__/category-transformer.test.ts +161 -0
  66. package/src/transformers/__tests__/comment-transformer.test.ts +129 -0
  67. package/src/transformers/__tests__/post-name-normalizer.test.ts +53 -0
  68. package/src/transformers/__tests__/post-transformer.test.ts +70 -0
  69. package/src/transformers/__tests__/query-normalizer.test.ts +98 -0
  70. package/src/transformers/__tests__/team-name-normalizer.test.ts +21 -0
  71. package/src/transformers/category-transformer.ts +36 -0
  72. package/src/transformers/comment-transformer.ts +34 -0
  73. package/src/transformers/post-name-normalizer.ts +30 -0
  74. package/src/transformers/post-transformer.ts +38 -0
  75. package/src/transformers/query-normalizer.ts +36 -0
  76. package/src/transformers/team-name-normalizer.ts +7 -0
  77. package/tsconfig.build.json +4 -0
  78. package/tsconfig.json +30 -0
  79. package/tsdown.config.ts +13 -0
  80. package/vitest.config.ts +24 -0
@@ -0,0 +1,161 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ transformCategory,
4
+ transformCategoryList,
5
+ } from "../category-transformer.js";
6
+
7
+ describe("transformCategory", () => {
8
+ it("should transform category with all properties", () => {
9
+ const category = {
10
+ name: "api",
11
+ full_name: "dev/docs/api",
12
+ count: 15,
13
+ has_child: true,
14
+ selected: true,
15
+ };
16
+
17
+ const result = transformCategory(category);
18
+
19
+ expect(result).toEqual({
20
+ full_name: "dev/docs/api",
21
+ count: 15,
22
+ has_child: true,
23
+ });
24
+ });
25
+
26
+ it("should transform category with minimal properties", () => {
27
+ const category = {
28
+ name: "simple",
29
+ count: 5,
30
+ };
31
+
32
+ const result = transformCategory(category);
33
+
34
+ expect(result).toEqual({
35
+ full_name: undefined,
36
+ count: 5,
37
+ has_child: false,
38
+ });
39
+ });
40
+ });
41
+
42
+ describe("transformCategoryList", () => {
43
+ it("should transform category list with all properties", () => {
44
+ const categoryList = {
45
+ current_category: "dev/docs",
46
+ categories: [
47
+ {
48
+ name: "api",
49
+ full_name: "dev/docs/api",
50
+ count: 10,
51
+ has_child: true,
52
+ },
53
+ {
54
+ name: "guides",
55
+ full_name: "dev/docs/guides",
56
+ count: 5,
57
+ has_child: false,
58
+ },
59
+ ],
60
+ parent_categories: [
61
+ {
62
+ current_category: "dev",
63
+ categories: [
64
+ {
65
+ name: "docs",
66
+ full_name: "dev/docs",
67
+ count: 15,
68
+ has_child: true,
69
+ },
70
+ ],
71
+ },
72
+ ],
73
+ no_category: {
74
+ name: "no_category",
75
+ count: 3,
76
+ },
77
+ descendant_posts: true,
78
+ total_count: 2,
79
+ per_page: 20,
80
+ page: 1,
81
+ prev_page: null,
82
+ next_page: null,
83
+ max_per_page: 100,
84
+ };
85
+
86
+ const result = transformCategoryList(categoryList);
87
+
88
+ expect(result).toEqual({
89
+ current_category: "dev/docs",
90
+ categories: [
91
+ {
92
+ full_name: "dev/docs/api",
93
+ count: 10,
94
+ has_child: true,
95
+ },
96
+ {
97
+ full_name: "dev/docs/guides",
98
+ count: 5,
99
+ has_child: false,
100
+ },
101
+ ],
102
+ parent_categories: [
103
+ {
104
+ current_category: "dev",
105
+ categories: [
106
+ {
107
+ full_name: "dev/docs",
108
+ count: 15,
109
+ has_child: true,
110
+ },
111
+ ],
112
+ },
113
+ ],
114
+ readme: undefined,
115
+ no_category: {
116
+ full_name: undefined,
117
+ count: 3,
118
+ has_child: false,
119
+ },
120
+ descendant_posts: true,
121
+ posts: undefined,
122
+ total_count: 2,
123
+ per_page: 20,
124
+ page: 1,
125
+ prev_page: null,
126
+ next_page: null,
127
+ max_per_page: 100,
128
+ });
129
+ });
130
+
131
+ it("should transform category list with minimal properties", () => {
132
+ const categoryList = {
133
+ current_category: "",
134
+ categories: [],
135
+ total_count: 0,
136
+ per_page: 20,
137
+ page: 1,
138
+ prev_page: null,
139
+ next_page: null,
140
+ max_per_page: 100,
141
+ };
142
+
143
+ const result = transformCategoryList(categoryList);
144
+
145
+ expect(result).toEqual({
146
+ current_category: "",
147
+ categories: [],
148
+ parent_categories: undefined,
149
+ readme: undefined,
150
+ no_category: undefined,
151
+ descendant_posts: undefined,
152
+ posts: undefined,
153
+ total_count: 0,
154
+ per_page: 20,
155
+ page: 1,
156
+ prev_page: null,
157
+ next_page: null,
158
+ max_per_page: 100,
159
+ });
160
+ });
161
+ });
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createLongContentComment,
4
+ createMockComment,
5
+ createNullBodyComment,
6
+ } from "../../__tests__/fixtures/mock-comment.js";
7
+ import { transformComment } from "../comment-transformer.js";
8
+
9
+ describe("transformComment", () => {
10
+ it("should transform comment data correctly", () => {
11
+ const mockComment = createMockComment();
12
+ const result = transformComment(mockComment);
13
+
14
+ expect(result).toEqual({
15
+ id: 123,
16
+ post_number: 456,
17
+ url: "https://test-team.esa.example.com/posts/456#comment-123",
18
+ body_md: "This is a test comment content",
19
+ created_at: "2024-01-01T00:00:00+09:00",
20
+ updated_at: "2024-01-01T01:00:00+09:00",
21
+ created_by: {
22
+ name: "Test User",
23
+ screen_name: "testuser",
24
+ icon: "https://example.com/icon.png",
25
+ myself: true,
26
+ },
27
+ stats: {
28
+ stargazers_count: 5,
29
+ star: true,
30
+ },
31
+ stargazers: [
32
+ {
33
+ created_at: "2024-01-01T02:00:00+09:00",
34
+ body: "Great comment!",
35
+ user: {
36
+ name: "Stargazer User",
37
+ screen_name: "stargazer",
38
+ icon: "https://example.com/star-icon.png",
39
+ myself: false,
40
+ },
41
+ name: "Stargazer User",
42
+ screen_name: "stargazer",
43
+ icon: "https://example.com/star-icon.png",
44
+ myself: false,
45
+ email: "stargazer@example.com",
46
+ role: "member",
47
+ posts_count: 10,
48
+ joined_at: "2024-01-01T00:00:00+09:00",
49
+ last_accessed_at: "2024-01-01T02:00:00+09:00",
50
+ },
51
+ ],
52
+ });
53
+ });
54
+
55
+ it("should handle comment with no stargazers", () => {
56
+ const commentWithoutStars = createMockComment({
57
+ stargazers_count: 0,
58
+ star: false,
59
+ stargazers: undefined,
60
+ });
61
+ const result = transformComment(commentWithoutStars);
62
+
63
+ expect(result.stats).toEqual({
64
+ stargazers_count: 0,
65
+ star: false,
66
+ });
67
+ expect(result.stargazers).toBe(undefined);
68
+ });
69
+
70
+ it("should handle comment with empty body_md", () => {
71
+ const commentWithEmptyBody = createNullBodyComment();
72
+ const result = transformComment(commentWithEmptyBody);
73
+
74
+ expect(result.body_md).toBe("");
75
+ });
76
+
77
+ it("should truncate long body_md when truncateBody option is provided", () => {
78
+ const longComment = createLongContentComment(400);
79
+
80
+ const result = transformComment(longComment, { truncateBody: 300 });
81
+
82
+ expect(result.body_md).toBe(`${"a".repeat(300)}...`);
83
+ });
84
+
85
+ it("should not truncate short body_md even when truncateBody option is provided", () => {
86
+ const shortComment = createMockComment({ body_md: "Short comment" });
87
+
88
+ const result = transformComment(shortComment, { truncateBody: 300 });
89
+
90
+ expect(result.body_md).toBe("Short comment");
91
+ });
92
+
93
+ it("should handle comment with empty body_md when truncateBody option is provided", () => {
94
+ const commentWithEmptyBody = createNullBodyComment();
95
+ const result = transformComment(commentWithEmptyBody, {
96
+ truncateBody: 300,
97
+ });
98
+
99
+ expect(result.body_md).toBe("");
100
+ });
101
+
102
+ it("should preserve all essential comment metadata", () => {
103
+ const mockComment = createMockComment({
104
+ id: 999,
105
+ post_number: 789,
106
+ url: "https://custom-team.esa.example.com/posts/789#comment-999",
107
+ created_at: "2024-06-01T12:00:00+09:00",
108
+ updated_at: "2024-06-01T13:00:00+09:00",
109
+ created_by: {
110
+ name: "Custom User",
111
+ screen_name: "customuser",
112
+ icon: "https://example.com/custom-icon.png",
113
+ myself: false,
114
+ },
115
+ });
116
+
117
+ const result = transformComment(mockComment);
118
+
119
+ expect(result.id).toBe(999);
120
+ expect(result.post_number).toBe(789);
121
+ expect(result.url).toBe(
122
+ "https://custom-team.esa.example.com/posts/789#comment-999",
123
+ );
124
+ expect(result.created_at).toBe("2024-06-01T12:00:00+09:00");
125
+ expect(result.updated_at).toBe("2024-06-01T13:00:00+09:00");
126
+ expect(result.created_by.name).toBe("Custom User");
127
+ expect(result.created_by.screen_name).toBe("customuser");
128
+ });
129
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizePostName } from "../post-name-normalizer.js";
3
+
4
+ describe("normalizePostName", () => {
5
+ it("should split name with slashes", () => {
6
+ expect(normalizePostName("docs/api/authentication")).toEqual({
7
+ name: "authentication",
8
+ category: "docs/api",
9
+ });
10
+ });
11
+
12
+ it("should not split when category is specified", () => {
13
+ expect(normalizePostName("docs/guide", "custom")).toEqual({
14
+ name: "docs/guide",
15
+ category: "custom",
16
+ });
17
+ });
18
+
19
+ it("should preserve empty string category", () => {
20
+ expect(normalizePostName("docs/guide", "")).toEqual({
21
+ name: "docs/guide",
22
+ category: "",
23
+ });
24
+ });
25
+
26
+ it("should handle name without slashes", () => {
27
+ expect(normalizePostName("simple")).toEqual({
28
+ name: "simple",
29
+ category: undefined,
30
+ });
31
+ });
32
+
33
+ it("should handle trailing slash as undefined name", () => {
34
+ expect(normalizePostName("folder/")).toEqual({
35
+ name: undefined,
36
+ category: "folder",
37
+ });
38
+ });
39
+
40
+ it("should handle leading slash", () => {
41
+ expect(normalizePostName("/guide")).toEqual({
42
+ name: "guide",
43
+ category: "",
44
+ });
45
+ });
46
+
47
+ it("should handle undefined name", () => {
48
+ expect(normalizePostName(undefined, "category")).toEqual({
49
+ name: undefined,
50
+ category: "category",
51
+ });
52
+ });
53
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createLongContentPost,
4
+ createMockPost,
5
+ createNullBodyPost,
6
+ } from "../../__tests__/fixtures/mock-post.js";
7
+ import { transformPost } from "../post-transformer.js";
8
+
9
+ describe("transformPost", () => {
10
+ it("should transform post data correctly", () => {
11
+ const mockPost = createMockPost();
12
+ const result = transformPost(mockPost);
13
+
14
+ expect(result).toEqual({
15
+ url: mockPost.url,
16
+ wip: "Shipped",
17
+ kind: "stock",
18
+ category_and_title_and_tags: mockPost.full_name,
19
+ body_md: mockPost.body_md,
20
+ created_at: mockPost.created_at,
21
+ updated_at: mockPost.updated_at,
22
+ created_by: mockPost.created_by,
23
+ updated_by: mockPost.updated_by,
24
+ stats: {
25
+ tasks_count: 3,
26
+ done_tasks_count: 2,
27
+ comments_count: 5,
28
+ stargazers_count: 10,
29
+ watchers_count: 8,
30
+ },
31
+ });
32
+ });
33
+
34
+ it("should transform WIP post correctly", () => {
35
+ const wipPost = createMockPost({ wip: true });
36
+ const result = transformPost(wipPost);
37
+
38
+ expect(result.wip).toBe("WIP");
39
+ });
40
+
41
+ it("should handle post with undefined body_md", () => {
42
+ const postWithUndefinedBody = createNullBodyPost();
43
+ const result = transformPost(postWithUndefinedBody);
44
+
45
+ expect(result.body_md).toBe(undefined);
46
+ });
47
+
48
+ it("should truncate long body_md when truncateBody option is provided", () => {
49
+ const longPost = createLongContentPost(600);
50
+
51
+ const result = transformPost(longPost, { truncateBody: 500 });
52
+
53
+ expect(result.body_md).toBe(`${"a".repeat(500)}...`);
54
+ });
55
+
56
+ it("should not truncate short body_md even when truncateBody option is provided", () => {
57
+ const shortPost = createMockPost({ body_md: "Short content" });
58
+
59
+ const result = transformPost(shortPost, { truncateBody: 500 });
60
+
61
+ expect(result.body_md).toBe("Short content");
62
+ });
63
+
64
+ it("should handle post with undefined body_md when truncateBody option is provided", () => {
65
+ const postWithUndefinedBody = createNullBodyPost();
66
+ const result = transformPost(postWithUndefinedBody, { truncateBody: 500 });
67
+
68
+ expect(result.body_md).toBe(undefined);
69
+ });
70
+ });
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeSearchQuery } from "../query-normalizer.js";
3
+
4
+ describe("normalizeSearchQuery", () => {
5
+ describe("wildcard normalization", () => {
6
+ it("should convert * to empty string", () => {
7
+ expect(normalizeSearchQuery("*")).toBe("");
8
+ });
9
+
10
+ it("should not convert * when part of a larger query", () => {
11
+ expect(normalizeSearchQuery("user:* tag:release")).toBe(
12
+ "user:* tag:release",
13
+ );
14
+ });
15
+ });
16
+
17
+ describe("date syntax normalization", () => {
18
+ it("should convert after: to created:>", () => {
19
+ expect(normalizeSearchQuery("after:2025-08-16")).toBe(
20
+ "created:>2025-08-16",
21
+ );
22
+ });
23
+
24
+ it("should convert before: to created:<", () => {
25
+ expect(normalizeSearchQuery("before:2025-08-16")).toBe(
26
+ "created:<2025-08-16",
27
+ );
28
+ });
29
+
30
+ it("should convert since: to created:>", () => {
31
+ expect(normalizeSearchQuery("since:2025-08-16")).toBe(
32
+ "created:>2025-08-16",
33
+ );
34
+ });
35
+
36
+ it("should convert until: to created:<", () => {
37
+ expect(normalizeSearchQuery("until:2025-08-16")).toBe(
38
+ "created:<2025-08-16",
39
+ );
40
+ });
41
+
42
+ it("should handle case-insensitive matching", () => {
43
+ expect(normalizeSearchQuery("AFTER:2025-08-16")).toBe(
44
+ "created:>2025-08-16",
45
+ );
46
+ expect(normalizeSearchQuery("After:2025-08-16")).toBe(
47
+ "created:>2025-08-16",
48
+ );
49
+ });
50
+
51
+ it("should handle multiple date patterns in one query", () => {
52
+ expect(
53
+ normalizeSearchQuery(
54
+ "user:fukayatsu after:2025-08-16 before:2025-08-22",
55
+ ),
56
+ ).toBe("user:fukayatsu created:>2025-08-16 created:<2025-08-22");
57
+ });
58
+
59
+ it("should not convert partial matches", () => {
60
+ expect(normalizeSearchQuery("notafter:2025-08-16")).toBe(
61
+ "notafter:2025-08-16",
62
+ );
63
+ expect(normalizeSearchQuery("after:notadate")).toBe("after:notadate");
64
+ });
65
+
66
+ it("should handle complex queries with mixed syntax", () => {
67
+ expect(
68
+ normalizeSearchQuery(
69
+ "user:fukayatsu after:2025-08-01 tag:release wip:false",
70
+ ),
71
+ ).toBe("user:fukayatsu created:>2025-08-01 tag:release wip:false");
72
+ });
73
+ });
74
+
75
+ describe("no transformation needed", () => {
76
+ it("should not modify valid esa syntax", () => {
77
+ expect(normalizeSearchQuery("created:>2025-08-16")).toBe(
78
+ "created:>2025-08-16",
79
+ );
80
+ expect(normalizeSearchQuery("updated:<2025-08-16")).toBe(
81
+ "updated:<2025-08-16",
82
+ );
83
+ });
84
+
85
+ it("should preserve normal search queries", () => {
86
+ expect(normalizeSearchQuery("user:fukayatsu tag:release")).toBe(
87
+ "user:fukayatsu tag:release",
88
+ );
89
+ expect(normalizeSearchQuery("category:dev wip:false")).toBe(
90
+ "category:dev wip:false",
91
+ );
92
+ });
93
+
94
+ it("should handle empty string", () => {
95
+ expect(normalizeSearchQuery("")).toBe("");
96
+ });
97
+ });
98
+ });
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeTeamName } from "../team-name-normalizer.js";
3
+
4
+ describe("normalizeTeamName", () => {
5
+ it("should extract team name from URL-like input", () => {
6
+ expect(normalizeTeamName("docs.example.com")).toBe("docs");
7
+ expect(normalizeTeamName("my-team.example.com")).toBe("my-team");
8
+ expect(normalizeTeamName("company.example.com")).toBe("company");
9
+ });
10
+
11
+ it("should leave normal team names unchanged", () => {
12
+ expect(normalizeTeamName("docs")).toBe("docs");
13
+ expect(normalizeTeamName("my-team")).toBe("my-team");
14
+ });
15
+
16
+ it("should handle edge cases", () => {
17
+ expect(normalizeTeamName("")).toBe("");
18
+ expect(normalizeTeamName(".")).toBe("");
19
+ expect(normalizeTeamName("team.")).toBe("team");
20
+ });
21
+ });
@@ -0,0 +1,36 @@
1
+ import type { components } from "../generated/api-types.js";
2
+
3
+ export function transformCategory(category: components["schemas"]["Category"]) {
4
+ return {
5
+ full_name: category.full_name,
6
+ count: category.count,
7
+ has_child: category.has_child || false,
8
+ };
9
+ }
10
+
11
+ export function transformCategoryList(
12
+ categoryList: components["schemas"]["CategoryList"],
13
+ ) {
14
+ return {
15
+ current_category: categoryList.current_category,
16
+ categories: categoryList.categories?.map(transformCategory) || [],
17
+ parent_categories: categoryList.parent_categories?.map(
18
+ (parentCategory) => ({
19
+ current_category: parentCategory.current_category,
20
+ categories: parentCategory.categories?.map(transformCategory) || [],
21
+ }),
22
+ ),
23
+ readme: categoryList.readme,
24
+ no_category: categoryList.no_category
25
+ ? transformCategory(categoryList.no_category)
26
+ : undefined,
27
+ descendant_posts: categoryList.descendant_posts,
28
+ posts: categoryList.posts,
29
+ total_count: categoryList.total_count,
30
+ per_page: categoryList.per_page,
31
+ page: categoryList.page,
32
+ prev_page: categoryList.prev_page,
33
+ next_page: categoryList.next_page,
34
+ max_per_page: categoryList.max_per_page,
35
+ };
36
+ }
@@ -0,0 +1,34 @@
1
+ import type { components } from "../generated/api-types.js";
2
+
3
+ export interface CommentTransformOptions {
4
+ truncateBody?: number;
5
+ }
6
+
7
+ export function transformComment(
8
+ comment: components["schemas"]["Comment"],
9
+ options: CommentTransformOptions = {},
10
+ ) {
11
+ const { truncateBody } = options;
12
+
13
+ let bodyMd = comment.body_md;
14
+ if (truncateBody && bodyMd && bodyMd.length > truncateBody) {
15
+ bodyMd = `${bodyMd.slice(0, truncateBody)}...`;
16
+ }
17
+
18
+ return {
19
+ id: comment.id,
20
+ post_number: comment.post_number,
21
+ url: comment.url,
22
+ body_md: bodyMd,
23
+ created_at: comment.created_at,
24
+ updated_at: comment.updated_at,
25
+ created_by: comment.created_by,
26
+ stats: {
27
+ stargazers_count: comment.stargazers_count,
28
+ star: comment.star,
29
+ },
30
+ stargazers: comment.stargazers,
31
+ };
32
+ }
33
+
34
+ export type TransformedComment = ReturnType<typeof transformComment>;
@@ -0,0 +1,30 @@
1
+ export interface PostNameParts {
2
+ name?: string;
3
+ category?: string;
4
+ }
5
+
6
+ export function normalizePostName(
7
+ name?: string,
8
+ category?: string,
9
+ ): PostNameParts {
10
+ if (!name) {
11
+ return { name, category };
12
+ }
13
+
14
+ if (category !== undefined) {
15
+ return { name, category };
16
+ }
17
+
18
+ if (name.includes("/")) {
19
+ const parts = name.split("/");
20
+ const extractedName = parts.pop();
21
+ const extractedCategory = parts.join("/");
22
+
23
+ return {
24
+ name: extractedName || undefined,
25
+ category: extractedCategory,
26
+ };
27
+ }
28
+
29
+ return { name, category };
30
+ }
@@ -0,0 +1,38 @@
1
+ import type { components } from "../generated/api-types.js";
2
+
3
+ export interface PostTransformOptions {
4
+ truncateBody?: number;
5
+ }
6
+
7
+ export function transformPost(
8
+ post: components["schemas"]["Post"],
9
+ options: PostTransformOptions = {},
10
+ ) {
11
+ const { truncateBody } = options;
12
+
13
+ let bodyMd = post.body_md;
14
+ if (truncateBody && bodyMd && bodyMd.length > truncateBody) {
15
+ bodyMd = `${bodyMd.slice(0, truncateBody)}...`;
16
+ }
17
+
18
+ return {
19
+ url: post.url,
20
+ wip: post.wip ? "WIP" : ("Shipped" as const),
21
+ kind: post.kind,
22
+ category_and_title_and_tags: post.full_name,
23
+ body_md: bodyMd,
24
+ created_at: post.created_at,
25
+ updated_at: post.updated_at,
26
+ created_by: post.created_by,
27
+ updated_by: post.updated_by,
28
+ stats: {
29
+ tasks_count: post.tasks_count,
30
+ done_tasks_count: post.done_tasks_count,
31
+ comments_count: post.comments_count,
32
+ stargazers_count: post.stargazers_count,
33
+ watchers_count: post.watchers_count,
34
+ },
35
+ };
36
+ }
37
+
38
+ export type TransformedPost = ReturnType<typeof transformPost>;