Skip to content

Commit dc3309b

Browse files
committed
wip
1 parent a3cfd64 commit dc3309b

9 files changed

Lines changed: 262 additions & 93 deletions

File tree

docs/WHY.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
<!-- 업데이트 -->
2+
13
# Why Yuki-no
24

35
Technical docs translation helps more people use technology and grows the open-source community. Many translation projects build their own processes using open-source tools instead of commercial solutions. This choice offers better cost control, scalability, flexibility, and data ownership.
@@ -36,7 +38,7 @@ _Example: Yuki-no automatically creates an issue for new commits in the head rep
3638
Yuki-no improves on existing solutions with several reliability enhancements:
3739

3840
- Keeps tracking accurate even when Actions fail by using successful run timestamps
39-
- Uses retry systems for GitHub API failures
41+
- Built-in retry and rate-limit handling for GitHub API calls
4042
- Provides detailed logs for better troubleshooting
4143

4244
These improvements ensure no commits are missed, even in challenging situations like Action failures or API rate limits. By tracking only successful Action runs as checkpoints, Yuki-no prevents losing commits during temporary failures. It automatically resumes from the last successful point. This makes it especially reliable for projects with frequent documentation updates.
@@ -65,6 +67,12 @@ Yuki-no addresses these diverse requirements through an extensible [plugin archi
6567
- **Custom Workflow Integration:** Hook into various stages of the tracking process to match your specific needs
6668
- **Modular Functionality:** Enable only the features you need, keeping your setup simple or complex as required
6769
- **Community Contributions:** Develop and share plugins with the community for common use cases
70+
- **Zero Install in Repo:** Plugins are automatically installed in GitHub Actions; you only list them in the workflow
71+
72+
Built-in/official plugins illustrate how the system scales:
73+
74+
- `@yuki-no/plugin-release-tracking`: Tracks release status per commit and updates labels/comments automatically
75+
- `@yuki-no/plugin-batch-pr`: Collects open translation issues and creates a single pull request consolidating changes (with exclusion patterns and optional root-dir mapping)
6876

6977
For instance, the built-in `release-tracking` plugin provides automated release status tracking using Issue Comments and Labels for projects where release management is critical. This plugin-based approach allows teams to build exactly the translation management system they need, whether simple or complex, while maintaining compatibility and ease of use.
7078

@@ -81,7 +89,7 @@ Yuki-no enhances GitHub Issues-based workflows with organizational features. The
8189
- Filter and manage translation tasks easily
8290
- Keep translation issues separate from general issues
8391

84-
These improvements create a more organized and efficient translation process while using GitHub's familiar interface.
92+
These improvements create a more organized and efficient translation process while using GitHub's familiar interface. When combined with plugins like `release-tracking` and `batch-pr`, teams can coordinate what is ready for release and consolidate approved work into streamlined pull requests.
8593

8694
### Yuki-no
8795

@@ -91,6 +99,7 @@ Yuki-no delivers all three essential features for technical docs translation pro
9199
- Offers simpler and clearer configuration
92100
- Provides `include` and `exclude` options based on [Glob patterns](https://github.com/micromatch/picomatch?tab=readme-ov-file#advanced-globbing)
93101
- Includes a `verbose` option for detailed logging
102+
- Supports an official plugin ecosystem (release tracking, batch PR)
94103

95104
If you want to start a translation project or add Yuki-no to an existing one, check out the [main guide](../README.md). For users of issue-based translation processes like Ryu-Cho, we have a [migration guide](./MIGRATION.md). For real examples, see the [vite/docs-ko repo](https://github.com/vitejs/docs-ko/blob/main/.github/workflows/sync.yml).
96105

packages/batch-pr/README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ Collects opened Yuki-no translation issues and creates a single pull request to
77
## Usage
88

99
```yaml
10+
permissions:
11+
# Default yuki-no permissions
12+
issues: write
13+
actions: read
14+
15+
# Required for branch creation and push operations
16+
contents: write
17+
18+
# Required for batch PR creation
19+
pull-requests: write
20+
1021
- uses: Gumball12/yuki-no@v1
1122
env:
1223
# [optional]
@@ -15,7 +26,8 @@ Collects opened Yuki-no translation issues and creates a single pull request to
1526
YUKI_NO_BATCH_PR_ROOT_DIR: head-repo-dirname
1627

1728
# [optional]
18-
# file patterns that should be excluded from batch PR
29+
# file patterns based on `head-repo`
30+
# that should be excluded from batch PR
1931
YUKI_NO_BATCH_PR_EXCLUDE: |
2032
head-repo-patterns
2133
with:

packages/batch-pr/utils/createPrBody.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ type PrBodyOptions = {
1111
excludedFiles?: string[];
1212
};
1313

14+
// TODO: YUKI_NO_BATCH_PR_ROOT_DIR 이용해 여기 기준으로 excluded files 필터링 & 명시해야 함
15+
// 안그럼 필요하지도 않은 애들까지 포함되어서 너무 길어지고 많아진다...
1416
export const createPrBody = (
1517
issueStatus: BatchIssueStatus[],
1618
{ excludedFiles } = {} as PrBodyOptions,

packages/core/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ const syncCommits = async (
8383
await p.onBeforeCompare?.({ ...ctx });
8484
}
8585

86-
const latestSuccessfulRun = await getLatestSuccessfulRunISODate(github);
86+
const latestSuccessfulRun = await getLatestSuccessfulRunISODate(
87+
github,
88+
false,
89+
);
8790
const commits = getCommits(config, git, latestSuccessfulRun);
8891
const notCreatedCommits = await lookupCommitsInIssues(github, commits);
8992

packages/core/tests/mockedRequests.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const ALLOWED_REPO = `${TEST_REPO.owner}/${TEST_REPO.repo}`;
1414
const auth = process.env.MOCKED_REQUEST_TEST;
1515
const shouldRunTests = isCI && currRepo === ALLOWED_REPO && auth;
1616

17+
// TODO: race condition 발생되지 않도록 테스트별로 서로 분리된 이슈 하나 만들어 그 안에서 진행
1718
describe('GitHub API Integration Tests', () => {
1819
if (!shouldRunTests) {
1920
it.skip('Skipping tests - not in CI environment or not in allowed repository', () => {
Lines changed: 93 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,115 @@
11
import { GitHub } from '../../infra/github';
22
import { getLatestSuccessfulRunISODate } from '../../utils-infra/getLatestSuccessfulRunISODate';
33

4-
import { beforeEach, expect, it, vi } from 'vitest';
5-
6-
const ACTION_NAME = 'yuki-no';
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
75

86
const mockListWorkflowRunsForRepo = vi.fn();
97

108
vi.mock('../../infra/github', () => ({
119
GitHub: vi.fn().mockImplementation(() => ({
12-
api: { actions: { listWorkflowRunsForRepo: mockListWorkflowRunsForRepo } },
10+
api: {
11+
rest: {
12+
actions: {
13+
listWorkflowRunsForRepo: mockListWorkflowRunsForRepo,
14+
},
15+
},
16+
},
17+
ownerAndRepo: { owner: 'test-owner', repo: 'test-repo' },
18+
configuredLabels: ['label1', 'label2'],
1319
})),
1420
}));
1521

16-
const MOCK_CONFIG = {
17-
accessToken: 'test-token',
18-
labels: ['test-label'],
19-
repoSpec: {
20-
owner: 'test-owner',
21-
name: 'test-repo',
22-
branch: 'main',
23-
},
24-
};
22+
vi.mock('../../utils-infra/getTranslationIssues', () => ({
23+
getTranslationIssues: vi.fn(),
24+
}));
2525

26-
const mockGitHub = new GitHub(MOCK_CONFIG);
26+
const mockGitHub = new GitHub({} as any);
2727

28-
beforeEach(() => {
29-
vi.clearAllMocks();
30-
});
28+
const mockGetTranslationIssues = (
29+
await import('../../utils-infra/getTranslationIssues')
30+
).getTranslationIssues as unknown as ReturnType<typeof vi.fn>;
3131

32-
it('Should return undefined when there are no successful workflow runs', async () => {
33-
mockListWorkflowRunsForRepo.mockResolvedValue({
34-
data: {
35-
workflow_runs: [],
36-
},
32+
describe('getLatestSuccessfulRunISODate', () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks();
3735
});
3836

39-
const result = await getLatestSuccessfulRunISODate(mockGitHub);
37+
it('First execution confirmed when (hint && no previous success && no issues) -> returns undefined', async () => {
38+
const hint = true;
4039

41-
expect(result).toBeUndefined();
42-
});
40+
mockListWorkflowRunsForRepo.mockResolvedValue({
41+
data: { total_count: 0, workflow_runs: [] },
42+
});
43+
mockGetTranslationIssues.mockResolvedValue([]);
4344

44-
it('Should return the last execution time when an action with the matching workflow name exists', async () => {
45-
const EXPECTED_LAST_CREATED_AT = '2023-01-04T12:00:00Z';
46-
47-
mockListWorkflowRunsForRepo.mockResolvedValue({
48-
data: {
49-
workflow_runs: [
50-
{ name: ACTION_NAME, created_at: '2023-01-03T12:00:00Z' },
51-
{ name: 'other-action', created_at: '2023-01-03T12:00:00Z' },
52-
{ name: 'another-action', created_at: '2023-01-02T12:00:00Z' },
53-
{ name: ACTION_NAME, created_at: EXPECTED_LAST_CREATED_AT },
54-
],
55-
},
45+
const result = await getLatestSuccessfulRunISODate(mockGitHub, hint);
46+
47+
expect(result).toBeUndefined();
48+
expect(mockGetTranslationIssues).toHaveBeenCalledWith(mockGitHub, 'all');
5649
});
5750

58-
const result = await getLatestSuccessfulRunISODate(mockGitHub);
51+
it('Not first execution when previous successful runs exist -> returns latest created_at by name match', async () => {
52+
const hint = true;
53+
const EXPECTED_LAST_CREATED_AT = '2023-01-04T12:00:00Z';
54+
55+
mockListWorkflowRunsForRepo.mockResolvedValue({
56+
data: {
57+
total_count: 3,
58+
workflow_runs: [
59+
{
60+
created_at: '2023-01-05T12:00:00Z',
61+
conclusion: 'success',
62+
name: 'other',
63+
},
64+
{
65+
created_at: EXPECTED_LAST_CREATED_AT,
66+
conclusion: 'success',
67+
name: 'yuki-no',
68+
},
69+
{
70+
created_at: '2023-01-03T12:00:00Z',
71+
conclusion: 'failure',
72+
name: 'yuki-no',
73+
},
74+
],
75+
},
76+
});
5977

60-
expect(result).toBe(EXPECTED_LAST_CREATED_AT);
78+
const result = await getLatestSuccessfulRunISODate(mockGitHub, hint);
79+
80+
expect(result).toBe(EXPECTED_LAST_CREATED_AT);
81+
});
82+
83+
it('No successful runs (but completed exist) and not first-run -> throws due to API inconsistency', async () => {
84+
mockListWorkflowRunsForRepo.mockResolvedValue({
85+
data: {
86+
total_count: 2,
87+
workflow_runs: [
88+
{
89+
created_at: '2023-01-05T12:00:00Z',
90+
conclusion: 'failure',
91+
name: 'yuki-no',
92+
},
93+
{
94+
created_at: '2023-01-04T12:00:00Z',
95+
conclusion: 'cancelled',
96+
name: 'yuki-no',
97+
},
98+
],
99+
},
100+
});
101+
mockGetTranslationIssues.mockResolvedValue([
102+
{
103+
number: 1,
104+
body: 'existing',
105+
labels: ['l1'],
106+
hash: 'h',
107+
isoDate: '2023-01-01T00:00:00Z',
108+
},
109+
]);
110+
111+
await expect(
112+
getLatestSuccessfulRunISODate(mockGitHub, false),
113+
).rejects.toThrow();
114+
});
61115
});
Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,47 @@
11
import type { GitHub } from '../infra/github';
22
import { log } from '../utils/log';
33

4+
import { getTranslationIssues } from './getTranslationIssues';
5+
6+
import type { RestEndpointMethodTypes } from '@octokit/rest';
7+
48
const WORKFLOW_NAME = 'yuki-no';
59

610
export const getLatestSuccessfulRunISODate = async (
711
github: GitHub,
12+
hintFirstRun: boolean,
813
): Promise<string | undefined> => {
914
log(
1015
'I',
1116
'getLatestSuccessfulRunISODate :: Extracting last successful GitHub Actions run time',
1217
);
13-
const { data } = await github.api.actions.listWorkflowRunsForRepo({
14-
...github.ownerAndRepo,
15-
status: 'success',
16-
});
1718

18-
const latestSuccessfulRun = data.workflow_runs
19-
.sort((a, b) => a.created_at.localeCompare(b.created_at))
20-
.findLast(run => run.name === WORKFLOW_NAME);
19+
const { run: latestSuccessfulRun, successfulCount } =
20+
await getLatestSuccessfulRun(github);
21+
const maybeFirstExecution =
22+
hintFirstRun && successfulCount === 0 && latestSuccessfulRun === undefined;
23+
24+
if (maybeFirstExecution) {
25+
const allIssues = await getTranslationIssues(github, 'all');
26+
const isFirstExecution = allIssues.length === 0;
27+
28+
if (isFirstExecution) {
29+
log(
30+
'I',
31+
'getLatestSuccessfulRunISODate :: No last successful GitHub Actions run time found (first execution confirmed)',
32+
);
33+
return;
34+
}
35+
}
2136

2237
if (!latestSuccessfulRun) {
2338
log(
24-
'I',
25-
'getLatestSuccessfulRunISODate :: No last successful GitHub Actions run time found',
39+
'W',
40+
`getLatestSuccessfulRunISODate :: API inconsistency detected: totalCount=${successfulCount}, but no successful run found`,
41+
);
42+
throw new Error(
43+
'GitHub API data inconsistency detected. This might indicate API instability.',
2644
);
27-
return;
2845
}
2946

3047
const latestSuccessfulRunDate = latestSuccessfulRun.created_at;
@@ -36,3 +53,32 @@ export const getLatestSuccessfulRunISODate = async (
3653

3754
return latestSuccessfulRunDate;
3855
};
56+
57+
type WorkflowRun =
58+
RestEndpointMethodTypes['actions']['listWorkflowRunsForRepo']['response']['data']['workflow_runs'][number];
59+
60+
const getLatestSuccessfulRun = async (
61+
github: GitHub,
62+
): Promise<{ run: WorkflowRun | undefined; successfulCount: number }> => {
63+
const { data } = await github.api.rest.actions.listWorkflowRunsForRepo({
64+
...github.ownerAndRepo,
65+
status: 'completed',
66+
per_page: 100,
67+
});
68+
69+
log(
70+
'I',
71+
`getLatestSuccessfulRunISODate :: Found ${data.total_count} completed / ${data.workflow_runs.length} runs on first page`,
72+
);
73+
74+
const successfulYukiNoRun = data.workflow_runs.filter(
75+
({ conclusion, name }) =>
76+
conclusion === 'success' && name === WORKFLOW_NAME,
77+
);
78+
const [latestSuccessfulRun] = successfulYukiNoRun;
79+
80+
return {
81+
run: latestSuccessfulRun,
82+
successfulCount: successfulYukiNoRun.length,
83+
};
84+
};

0 commit comments

Comments
 (0)