npm-cli-gh-issue-preparator 1.0.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 (91) hide show
  1. package/.env.example +0 -0
  2. package/.eslintrc.cjs +65 -0
  3. package/.github/CODEOWNERS +2 -0
  4. package/.github/workflows/commit-lint.yml +52 -0
  5. package/.github/workflows/configs/commitlint.config.js +27 -0
  6. package/.github/workflows/create-pr.yml +66 -0
  7. package/.github/workflows/empty-format-test-job.yml +28 -0
  8. package/.github/workflows/format.yml +25 -0
  9. package/.github/workflows/publish.yml +47 -0
  10. package/.github/workflows/test.yml +38 -0
  11. package/.github/workflows/umino-project.yml +191 -0
  12. package/.prettierignore +22 -0
  13. package/.prettierrc +5 -0
  14. package/CHANGELOG.md +27 -0
  15. package/CONTRIBUTING.md +107 -0
  16. package/README.md +49 -0
  17. package/bin/adapter/entry-points/cli/index.js +72 -0
  18. package/bin/adapter/entry-points/cli/index.js.map +1 -0
  19. package/bin/adapter/repositories/GitHubIssueRepository.js +340 -0
  20. package/bin/adapter/repositories/GitHubIssueRepository.js.map +1 -0
  21. package/bin/adapter/repositories/GitHubProjectRepository.js +123 -0
  22. package/bin/adapter/repositories/GitHubProjectRepository.js.map +1 -0
  23. package/bin/adapter/repositories/NodeLocalCommandRunner.js +34 -0
  24. package/bin/adapter/repositories/NodeLocalCommandRunner.js.map +1 -0
  25. package/bin/domain/entities/Issue.js +3 -0
  26. package/bin/domain/entities/Issue.js.map +1 -0
  27. package/bin/domain/entities/Project.js +3 -0
  28. package/bin/domain/entities/Project.js.map +1 -0
  29. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +37 -0
  30. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -0
  31. package/bin/domain/usecases/StartPreparationUseCase.js +31 -0
  32. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -0
  33. package/bin/domain/usecases/adapter-interfaces/IssueRepository.js +3 -0
  34. package/bin/domain/usecases/adapter-interfaces/IssueRepository.js.map +1 -0
  35. package/bin/domain/usecases/adapter-interfaces/LocalCommandRunner.js +3 -0
  36. package/bin/domain/usecases/adapter-interfaces/LocalCommandRunner.js.map +1 -0
  37. package/bin/domain/usecases/adapter-interfaces/ProjectRepository.js +3 -0
  38. package/bin/domain/usecases/adapter-interfaces/ProjectRepository.js.map +1 -0
  39. package/bin/index.js +6 -0
  40. package/bin/index.js.map +1 -0
  41. package/commitlint.config.js +6 -0
  42. package/jest.config.js +33 -0
  43. package/package.json +75 -0
  44. package/renovate.json +37 -0
  45. package/src/adapter/entry-points/cli/index.integration.test.ts +143 -0
  46. package/src/adapter/entry-points/cli/index.test.ts +165 -0
  47. package/src/adapter/entry-points/cli/index.ts +110 -0
  48. package/src/adapter/repositories/GitHubIssueRepository.integration.test.ts +50 -0
  49. package/src/adapter/repositories/GitHubIssueRepository.test.ts +996 -0
  50. package/src/adapter/repositories/GitHubIssueRepository.ts +470 -0
  51. package/src/adapter/repositories/GitHubProjectRepository.test.ts +252 -0
  52. package/src/adapter/repositories/GitHubProjectRepository.ts +162 -0
  53. package/src/adapter/repositories/NodeLocalCommandRunner.test.ts +80 -0
  54. package/src/adapter/repositories/NodeLocalCommandRunner.ts +37 -0
  55. package/src/domain/entities/Issue.ts +7 -0
  56. package/src/domain/entities/Project.ts +7 -0
  57. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +109 -0
  58. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +48 -0
  59. package/src/domain/usecases/StartPreparationUseCase.test.ts +150 -0
  60. package/src/domain/usecases/StartPreparationUseCase.ts +48 -0
  61. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +8 -0
  62. package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +7 -0
  63. package/src/domain/usecases/adapter-interfaces/ProjectRepository.ts +5 -0
  64. package/src/index.test.ts +7 -0
  65. package/src/index.ts +3 -0
  66. package/tsconfig.build.json +11 -0
  67. package/tsconfig.json +16 -0
  68. package/types/adapter/entry-points/cli/index.d.ts +5 -0
  69. package/types/adapter/entry-points/cli/index.d.ts.map +1 -0
  70. package/types/adapter/repositories/GitHubIssueRepository.d.ts +14 -0
  71. package/types/adapter/repositories/GitHubIssueRepository.d.ts.map +1 -0
  72. package/types/adapter/repositories/GitHubProjectRepository.d.ts +9 -0
  73. package/types/adapter/repositories/GitHubProjectRepository.d.ts.map +1 -0
  74. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts +9 -0
  75. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts.map +1 -0
  76. package/types/domain/entities/Issue.d.ts +8 -0
  77. package/types/domain/entities/Issue.d.ts.map +1 -0
  78. package/types/domain/entities/Project.d.ts +8 -0
  79. package/types/domain/entities/Project.d.ts.map +1 -0
  80. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +20 -0
  81. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -0
  82. package/types/domain/usecases/StartPreparationUseCase.d.ts +17 -0
  83. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -0
  84. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +8 -0
  85. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -0
  86. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +8 -0
  87. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -0
  88. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts +5 -0
  89. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts.map +1 -0
  90. package/types/index.d.ts +3 -0
  91. package/types/index.d.ts.map +1 -0
@@ -0,0 +1,162 @@
1
+ import { ProjectRepository } from '../../domain/usecases/adapter-interfaces/ProjectRepository';
2
+ import { Project } from '../../domain/entities/Project';
3
+
4
+ type GitHubProjectField = {
5
+ name: string;
6
+ options?: Array<{ name: string }>;
7
+ };
8
+
9
+ type GitHubProjectV2 = {
10
+ id: string;
11
+ title: string;
12
+ url: string;
13
+ fields: {
14
+ nodes: GitHubProjectField[];
15
+ };
16
+ };
17
+
18
+ type GitHubApiResponse = {
19
+ data?: {
20
+ organization?: {
21
+ projectV2?: GitHubProjectV2;
22
+ };
23
+ user?: {
24
+ projectV2?: GitHubProjectV2;
25
+ };
26
+ };
27
+ };
28
+
29
+ function isGitHubApiResponse(value: unknown): value is GitHubApiResponse {
30
+ if (typeof value !== 'object' || value === null) return false;
31
+ return true;
32
+ }
33
+
34
+ export class GitHubProjectRepository implements ProjectRepository {
35
+ constructor(private readonly token: string) {}
36
+
37
+ private parseGitHubProjectUrl(url: string): {
38
+ owner: string;
39
+ projectNumber: string;
40
+ } {
41
+ const orgMatch = url.match(/github\.com\/orgs\/([^/]+)\/projects\/(\d+)/);
42
+ if (orgMatch) {
43
+ return {
44
+ owner: orgMatch[1],
45
+ projectNumber: orgMatch[2],
46
+ };
47
+ }
48
+
49
+ const userMatch = url.match(/github\.com\/users\/([^/]+)\/projects\/(\d+)/);
50
+ if (userMatch) {
51
+ return {
52
+ owner: userMatch[1],
53
+ projectNumber: userMatch[2],
54
+ };
55
+ }
56
+
57
+ const repoMatch = url.match(
58
+ /github\.com\/([^/]+)\/([^/]+)\/projects\/(\d+)/,
59
+ );
60
+ if (repoMatch) {
61
+ return {
62
+ owner: repoMatch[1],
63
+ projectNumber: repoMatch[3],
64
+ };
65
+ }
66
+
67
+ throw new Error(`Invalid GitHub project URL: ${url}`);
68
+ }
69
+
70
+ async getByUrl(url: string): Promise<Project> {
71
+ const { owner, projectNumber } = this.parseGitHubProjectUrl(url);
72
+
73
+ const projectQuery = `
74
+ query($owner: String!, $number: Int!) {
75
+ organization(login: $owner) {
76
+ projectV2(number: $number) {
77
+ id
78
+ title
79
+ url
80
+ fields(first: 100) {
81
+ nodes {
82
+ ... on ProjectV2SingleSelectField {
83
+ name
84
+ options {
85
+ name
86
+ }
87
+ }
88
+ ... on ProjectV2Field {
89
+ name
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
95
+ user(login: $owner) {
96
+ projectV2(number: $number) {
97
+ id
98
+ title
99
+ url
100
+ fields(first: 100) {
101
+ nodes {
102
+ ... on ProjectV2SingleSelectField {
103
+ name
104
+ options {
105
+ name
106
+ }
107
+ }
108
+ ... on ProjectV2Field {
109
+ name
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ `;
117
+
118
+ const response = await fetch('https://api.github.com/graphql', {
119
+ method: 'POST',
120
+ headers: {
121
+ Authorization: `Bearer ${this.token}`,
122
+ 'Content-Type': 'application/json',
123
+ },
124
+ body: JSON.stringify({
125
+ query: projectQuery,
126
+ variables: {
127
+ owner,
128
+ number: parseInt(projectNumber, 10),
129
+ },
130
+ }),
131
+ });
132
+
133
+ if (!response.ok) {
134
+ const errorText = await response.text();
135
+ throw new Error(`GitHub API error: ${errorText}`);
136
+ }
137
+
138
+ const responseData: unknown = await response.json();
139
+ if (!isGitHubApiResponse(responseData)) {
140
+ throw new Error('Invalid API response format');
141
+ }
142
+
143
+ const result: GitHubApiResponse = responseData;
144
+ const project =
145
+ result.data?.organization?.projectV2 || result.data?.user?.projectV2;
146
+ if (!project) {
147
+ throw new Error(`Project not found: ${url}`);
148
+ }
149
+
150
+ const fields = project.fields.nodes;
151
+ const statusField = fields.find((f) => f.name === 'Status');
152
+ const statuses: string[] = statusField?.options?.map((o) => o.name) || [];
153
+
154
+ return {
155
+ id: project.id,
156
+ url: project.url,
157
+ name: project.title,
158
+ statuses,
159
+ customFieldNames: fields.map((f) => f.name),
160
+ };
161
+ }
162
+ }
@@ -0,0 +1,80 @@
1
+ const mockExecAsync = jest.fn();
2
+
3
+ jest.mock('child_process', () => ({
4
+ exec: jest.fn(),
5
+ }));
6
+
7
+ jest.mock('util', () => ({
8
+ promisify: jest.fn(() => mockExecAsync),
9
+ }));
10
+
11
+ import { NodeLocalCommandRunner } from './NodeLocalCommandRunner';
12
+
13
+ describe('NodeLocalCommandRunner', () => {
14
+ let runner: NodeLocalCommandRunner;
15
+
16
+ beforeEach(() => {
17
+ jest.clearAllMocks();
18
+ runner = new NodeLocalCommandRunner();
19
+ });
20
+
21
+ describe('runCommand', () => {
22
+ it('should execute command successfully', async () => {
23
+ mockExecAsync.mockResolvedValue({
24
+ stdout: 'command output',
25
+ stderr: '',
26
+ });
27
+
28
+ const result = await runner.runCommand('echo "test"');
29
+
30
+ expect(result).toEqual({
31
+ stdout: 'command output',
32
+ stderr: '',
33
+ exitCode: 0,
34
+ });
35
+ expect(mockExecAsync).toHaveBeenCalledWith('echo "test"');
36
+ });
37
+
38
+ it('should handle command errors with exit code', async () => {
39
+ const error = Object.assign(new Error('Command failed'), {
40
+ code: 2,
41
+ stdout: 'partial output',
42
+ stderr: 'error message',
43
+ });
44
+ mockExecAsync.mockRejectedValue(error);
45
+
46
+ const result = await runner.runCommand('invalid-command');
47
+
48
+ expect(result).toEqual({
49
+ stdout: 'partial output',
50
+ stderr: 'error message',
51
+ exitCode: 2,
52
+ });
53
+ });
54
+
55
+ it('should default to exit code 1 when code is not a number', async () => {
56
+ const error = Object.assign(new Error('Command failed'), {
57
+ code: 'ENOENT',
58
+ stdout: '',
59
+ stderr: 'command not found',
60
+ });
61
+ mockExecAsync.mockRejectedValue(error);
62
+
63
+ const result = await runner.runCommand('nonexistent');
64
+
65
+ expect(result).toEqual({
66
+ stdout: '',
67
+ stderr: 'command not found',
68
+ exitCode: 1,
69
+ });
70
+ });
71
+
72
+ it('should throw error if error format is unexpected', async () => {
73
+ mockExecAsync.mockRejectedValue(new Error('Unexpected error'));
74
+
75
+ await expect(runner.runCommand('command')).rejects.toThrow(
76
+ 'Unexpected error',
77
+ );
78
+ });
79
+ });
80
+ });
@@ -0,0 +1,37 @@
1
+ import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
2
+ import { exec } from 'child_process';
3
+ import { promisify } from 'util';
4
+
5
+ const execAsync = promisify(exec);
6
+
7
+ export class NodeLocalCommandRunner implements LocalCommandRunner {
8
+ async runCommand(command: string): Promise<{
9
+ stdout: string;
10
+ stderr: string;
11
+ exitCode: number;
12
+ }> {
13
+ try {
14
+ const { stdout, stderr } = await execAsync(command);
15
+ return {
16
+ stdout,
17
+ stderr,
18
+ exitCode: 0,
19
+ };
20
+ } catch (error) {
21
+ if (
22
+ error &&
23
+ typeof error === 'object' &&
24
+ 'stdout' in error &&
25
+ 'stderr' in error &&
26
+ 'code' in error
27
+ ) {
28
+ return {
29
+ stdout: String(error.stdout),
30
+ stderr: String(error.stderr),
31
+ exitCode: typeof error.code === 'number' ? error.code : 1,
32
+ };
33
+ }
34
+ throw error;
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,7 @@
1
+ export type Issue = {
2
+ id: string;
3
+ url: string;
4
+ title: string;
5
+ labels: string[];
6
+ status: string;
7
+ };
@@ -0,0 +1,7 @@
1
+ export type Project = {
2
+ id: string;
3
+ url: string;
4
+ name: string;
5
+ statuses: string[];
6
+ customFieldNames: string[];
7
+ };
@@ -0,0 +1,109 @@
1
+ import { NotifyFinishedIssuePreparationUseCase } from './NotifyFinishedIssuePreparationUseCase';
2
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
3
+ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
4
+ import { Issue } from '../entities/Issue';
5
+ import { Project } from '../entities/Project';
6
+
7
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
8
+
9
+ describe('NotifyFinishedIssuePreparationUseCase', () => {
10
+ let useCase: NotifyFinishedIssuePreparationUseCase;
11
+ let mockProjectRepository: Mocked<ProjectRepository>;
12
+ let mockIssueRepository: Mocked<IssueRepository>;
13
+
14
+ const mockProject: Project = {
15
+ id: 'project-1',
16
+ url: 'https://github.com/user/repo',
17
+ name: 'Test Project',
18
+ statuses: ['Preparation', 'Awaiting Quality Check', 'Done'],
19
+ customFieldNames: ['workspace'],
20
+ };
21
+
22
+ beforeEach(() => {
23
+ jest.resetAllMocks();
24
+
25
+ mockProjectRepository = {
26
+ getByUrl: jest.fn(),
27
+ };
28
+
29
+ mockIssueRepository = {
30
+ getAllOpened: jest.fn(),
31
+ get: jest.fn(),
32
+ update: jest.fn(),
33
+ };
34
+
35
+ useCase = new NotifyFinishedIssuePreparationUseCase(
36
+ mockProjectRepository,
37
+ mockIssueRepository,
38
+ );
39
+ });
40
+
41
+ it('should update issue status from Preparation to Awaiting Quality Check', async () => {
42
+ const issue: Issue = {
43
+ id: '1',
44
+ url: 'https://github.com/user/repo/issues/1',
45
+ title: 'Test Issue',
46
+ labels: [],
47
+ status: 'Preparation',
48
+ };
49
+
50
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
51
+ mockIssueRepository.get.mockResolvedValue(issue);
52
+
53
+ await useCase.run({
54
+ projectUrl: 'https://github.com/user/repo',
55
+ issueUrl: 'https://github.com/user/repo/issues/1',
56
+ preparationStatus: 'Preparation',
57
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
58
+ });
59
+
60
+ expect(mockIssueRepository.update).toHaveBeenCalledTimes(1);
61
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
62
+ expect.objectContaining({
63
+ id: '1',
64
+ status: 'Awaiting Quality Check',
65
+ }),
66
+ mockProject,
67
+ );
68
+ });
69
+
70
+ it('should throw IssueNotFoundError when issue does not exist', async () => {
71
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
72
+ mockIssueRepository.get.mockResolvedValue(null);
73
+
74
+ await expect(
75
+ useCase.run({
76
+ projectUrl: 'https://github.com/user/repo',
77
+ issueUrl: 'https://github.com/user/repo/issues/999',
78
+ preparationStatus: 'Preparation',
79
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
80
+ }),
81
+ ).rejects.toThrow(
82
+ 'Issue not found: https://github.com/user/repo/issues/999',
83
+ );
84
+ });
85
+
86
+ it('should throw IllegalIssueStatusError when issue status is not Preparation', async () => {
87
+ const issue: Issue = {
88
+ id: '1',
89
+ url: 'https://github.com/user/repo/issues/1',
90
+ title: 'Test Issue',
91
+ labels: [],
92
+ status: 'Done',
93
+ };
94
+
95
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
96
+ mockIssueRepository.get.mockResolvedValue(issue);
97
+
98
+ await expect(
99
+ useCase.run({
100
+ projectUrl: 'https://github.com/user/repo',
101
+ issueUrl: 'https://github.com/user/repo/issues/1',
102
+ preparationStatus: 'Preparation',
103
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
104
+ }),
105
+ ).rejects.toThrow(
106
+ 'Illegal issue status for https://github.com/user/repo/issues/1: expected Preparation, but got Done',
107
+ );
108
+ });
109
+ });
@@ -0,0 +1,48 @@
1
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
2
+ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
3
+
4
+ export class IssueNotFoundError extends Error {
5
+ constructor(issueUrl: string) {
6
+ super(`Issue not found: ${issueUrl}`);
7
+ this.name = 'IssueNotFoundError';
8
+ }
9
+ }
10
+ export class IllegalIssueStatusError extends Error {
11
+ constructor(issueUrl: string, currentStatus: string, expectedStatus: string) {
12
+ super(
13
+ `Illegal issue status for ${issueUrl}: expected ${expectedStatus}, but got ${currentStatus}`,
14
+ );
15
+ this.name = 'IllegalIssueStatusError';
16
+ }
17
+ }
18
+
19
+ export class NotifyFinishedIssuePreparationUseCase {
20
+ constructor(
21
+ private readonly projectRepository: ProjectRepository,
22
+ private readonly issueRepository: IssueRepository,
23
+ ) {}
24
+
25
+ run = async (params: {
26
+ projectUrl: string;
27
+ issueUrl: string;
28
+ preparationStatus: string;
29
+ awaitingQualityCheckStatus: string;
30
+ }): Promise<void> => {
31
+ const project = await this.projectRepository.getByUrl(params.projectUrl);
32
+
33
+ const issue = await this.issueRepository.get(params.issueUrl, project);
34
+
35
+ if (!issue) {
36
+ throw new IssueNotFoundError(params.issueUrl);
37
+ } else if (issue.status !== params.preparationStatus) {
38
+ throw new IllegalIssueStatusError(
39
+ params.issueUrl,
40
+ issue.status,
41
+ params.preparationStatus,
42
+ );
43
+ }
44
+
45
+ issue.status = params.awaitingQualityCheckStatus;
46
+ await this.issueRepository.update(issue, project);
47
+ };
48
+ }
@@ -0,0 +1,150 @@
1
+ import { StartPreparationUseCase } from './StartPreparationUseCase';
2
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
3
+ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
4
+ import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
5
+ import { Issue } from '../entities/Issue';
6
+ import { Project } from '../entities/Project';
7
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
8
+ describe('StartPreparationUseCase', () => {
9
+ let useCase: StartPreparationUseCase;
10
+ let mockProjectRepository: Mocked<ProjectRepository>;
11
+ let mockIssueRepository: Mocked<IssueRepository>;
12
+ let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
13
+ const mockProject: Project = {
14
+ id: 'project-1',
15
+ url: 'https://github.com/user/repo',
16
+ name: 'Test Project',
17
+ statuses: ['Awaiting Workspace', 'Preparation', 'Done'],
18
+ customFieldNames: ['workspace'],
19
+ };
20
+ beforeEach(() => {
21
+ jest.resetAllMocks();
22
+ mockProjectRepository = {
23
+ getByUrl: jest.fn(),
24
+ };
25
+ mockIssueRepository = {
26
+ getAllOpened: jest.fn(),
27
+ get: jest.fn(),
28
+ update: jest.fn(),
29
+ };
30
+ mockLocalCommandRunner = {
31
+ runCommand: jest.fn(),
32
+ };
33
+ useCase = new StartPreparationUseCase(
34
+ mockProjectRepository,
35
+ mockIssueRepository,
36
+ mockLocalCommandRunner,
37
+ );
38
+ });
39
+ it('should run aw command for awaiting workspace issues', async () => {
40
+ const awaitingIssues: Issue[] = [
41
+ {
42
+ id: '1',
43
+ url: 'url1',
44
+ title: 'Issue 1',
45
+ labels: ['category:impl'],
46
+ status: 'Awaiting Workspace',
47
+ },
48
+ ];
49
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
50
+ mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
51
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
52
+ stdout: '',
53
+ stderr: '',
54
+ exitCode: 0,
55
+ });
56
+ await useCase.run({
57
+ projectUrl: 'https://github.com/user/repo',
58
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
59
+ preparationStatus: 'Preparation',
60
+ defaultAgentName: 'agent1',
61
+ });
62
+ expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
63
+ expect(mockIssueRepository.update.mock.calls[0][0]).toMatchObject({
64
+ id: '1',
65
+ status: 'Preparation',
66
+ });
67
+ expect(mockIssueRepository.update.mock.calls[0][1]).toBe(mockProject);
68
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
69
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe(
70
+ 'aw https://github.com/user/repo url1 impl',
71
+ );
72
+ });
73
+ it('should assign workspace to awaiting issues', async () => {
74
+ const awaitingIssues: Issue[] = [
75
+ {
76
+ id: '1',
77
+ url: 'url1',
78
+ title: 'Issue 1',
79
+ labels: [],
80
+ status: 'Awaiting Workspace',
81
+ },
82
+ {
83
+ id: '2',
84
+ url: 'url2',
85
+ title: 'Issue 2',
86
+ labels: [],
87
+ status: 'Awaiting Workspace',
88
+ },
89
+ ];
90
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
91
+ mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
92
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
93
+ stdout: '',
94
+ stderr: '',
95
+ exitCode: 0,
96
+ });
97
+ await useCase.run({
98
+ projectUrl: 'https://github.com/user/repo',
99
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
100
+ preparationStatus: 'Preparation',
101
+ defaultAgentName: 'agent1',
102
+ });
103
+ expect(mockIssueRepository.update.mock.calls).toHaveLength(2);
104
+ expect(mockIssueRepository.update.mock.calls[0][0]).toMatchObject({
105
+ id: '1',
106
+ status: 'Preparation',
107
+ });
108
+ expect(mockIssueRepository.update.mock.calls[0][1]).toBe(mockProject);
109
+ expect(mockIssueRepository.update.mock.calls[1][0]).toMatchObject({
110
+ id: '2',
111
+ status: 'Preparation',
112
+ });
113
+ expect(mockIssueRepository.update.mock.calls[1][1]).toBe(mockProject);
114
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(2);
115
+ });
116
+ it('should not assign workspace if maximum preparing issues reached', async () => {
117
+ const preparationIssues: Issue[] = Array.from({ length: 6 }, (_, i) => ({
118
+ id: `${i + 1}`,
119
+ url: `url${i + 1}`,
120
+ title: `Issue ${i + 1}`,
121
+ labels: [],
122
+ status: 'Preparation',
123
+ }));
124
+ const awaitingIssues: Issue[] = [
125
+ {
126
+ id: '7',
127
+ url: 'url7',
128
+ title: 'Issue 7',
129
+ labels: [],
130
+ status: 'Awaiting Workspace',
131
+ },
132
+ ];
133
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
134
+ mockIssueRepository.getAllOpened.mockResolvedValueOnce([
135
+ ...preparationIssues,
136
+ ...awaitingIssues,
137
+ ]);
138
+ await useCase.run({
139
+ projectUrl: 'https://github.com/user/repo',
140
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
141
+ preparationStatus: 'Preparation',
142
+ defaultAgentName: 'agent1',
143
+ });
144
+ const issue7UpdateCalls = mockIssueRepository.update.mock.calls.filter(
145
+ (call) => call[0].id === '7',
146
+ );
147
+ expect(issue7UpdateCalls).toHaveLength(0);
148
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
149
+ });
150
+ });
@@ -0,0 +1,48 @@
1
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
2
+ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
3
+ import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
4
+
5
+ export class StartPreparationUseCase {
6
+ maximumPreparingIssuesCount = 6;
7
+ constructor(
8
+ private readonly projectRepository: ProjectRepository,
9
+ private readonly issueRepository: IssueRepository,
10
+ private readonly localCommandRunner: LocalCommandRunner,
11
+ ) {}
12
+
13
+ run = async (params: {
14
+ projectUrl: string;
15
+ awaitingWorkspaceStatus: string;
16
+ preparationStatus: string;
17
+ defaultAgentName: string;
18
+ }): Promise<void> => {
19
+ const project = await this.projectRepository.getByUrl(params.projectUrl);
20
+
21
+ const allIssues = await this.issueRepository.getAllOpened(project);
22
+
23
+ const awaitingWorkspaceIssues = allIssues.filter(
24
+ (issue) => issue.status === params.awaitingWorkspaceStatus,
25
+ );
26
+
27
+ if (
28
+ allIssues.filter((issue) => issue.status === params.preparationStatus)
29
+ .length >= this.maximumPreparingIssuesCount
30
+ ) {
31
+ return;
32
+ }
33
+
34
+ for (const issue of awaitingWorkspaceIssues) {
35
+ const agent =
36
+ issue.labels
37
+ .find((label) => label.startsWith('category:'))
38
+ ?.replace('category:', '')
39
+ .trim() || params.defaultAgentName;
40
+ issue.status = params.preparationStatus;
41
+ await this.issueRepository.update(issue, project);
42
+
43
+ await this.localCommandRunner.runCommand(
44
+ `aw ${project.url} ${issue.url} ${agent}`,
45
+ );
46
+ }
47
+ };
48
+ }
@@ -0,0 +1,8 @@
1
+ import { Issue } from '../../entities/Issue';
2
+ import { Project } from '../../entities/Project';
3
+
4
+ export interface IssueRepository {
5
+ getAllOpened(project: Project): Promise<Issue[]>;
6
+ get(issueUrl: string, project: Project): Promise<Issue | null>;
7
+ update(issue: Issue, project: Project): Promise<void>;
8
+ }
@@ -0,0 +1,7 @@
1
+ export interface LocalCommandRunner {
2
+ runCommand(command: string): Promise<{
3
+ stdout: string;
4
+ stderr: string;
5
+ exitCode: number;
6
+ }>;
7
+ }
@@ -0,0 +1,5 @@
1
+ import { Project } from '../../entities/Project';
2
+
3
+ export interface ProjectRepository {
4
+ getByUrl(url: string): Promise<Project>;
5
+ }
@@ -0,0 +1,7 @@
1
+ import { program } from './index';
2
+
3
+ describe('index', () => {
4
+ it('should export program from CLI module', () => {
5
+ expect(program).toBeDefined();
6
+ });
7
+ });
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { program } from './adapter/entry-points/cli/index';
2
+
3
+ export { program };