diff --git a/src/github-auth.cjs b/src/github-auth.cjs
index b4eb5be3..29a1dee2 100644
--- a/src/github-auth.cjs
+++ b/src/github-auth.cjs
@@ -31,6 +31,7 @@
// scope that fails to arrive produces a token that signs in fine and cannot
// push — a failure that surfaces at the last write of the whole flow.
const { postForm, getJson } = require('./github-http.cjs');
+const { __, sprintf } = require('@wordpress/i18n');
// The WordPress organisation's OAuth application, "WordPress Contributor
// Toolkit". An empty value here means this build has no sign-in configured,
@@ -94,7 +95,7 @@ async function requestDeviceCode(deps = {}) {
return {
ok: false,
reason: 'not-configured',
- error: 'This build has no GitHub application configured, so it cannot sign you in.'
+ error: __('This build has no GitHub application configured, so it cannot sign you in.')
};
}
@@ -112,11 +113,13 @@ async function requestDeviceCode(deps = {}) {
return {
ok: false,
reason: 'not-configured',
- error: 'GitHub does not recognise this application, or device flow is not enabled on it.'
+ error: __('GitHub does not recognise this application, or device flow is not enabled on it.')
};
}
if (res.status !== 200 || !res.json || !res.json.device_code) {
- const detail = res.json && res.json.error_description ? res.json.error_description : `GitHub returned ${res.status}`;
+ const detail = res.json && res.json.error_description ? res.json.error_description :
+ // translators: %s: an HTTP status code, such as 500.
+ sprintf(__('GitHub returned %s'), res.status);
return { ok: false, reason: 'error', error: detail };
}
@@ -163,13 +166,13 @@ async function pollForToken({ deviceCode, interval, expiresAt }, deps = {}) {
let waitSeconds = Number(interval) > 0 ? Number(interval) : DEFAULT_INTERVAL_SECONDS;
for (;;) {
- if (isCanceled()) return { ok: false, reason: 'canceled', error: 'Sign-in was canceled.' };
+ if (isCanceled()) return { ok: false, reason: 'canceled', error: __('Sign-in was canceled.') };
await wait(waitSeconds * 1000);
- if (isCanceled()) return { ok: false, reason: 'canceled', error: 'Sign-in was canceled.' };
+ if (isCanceled()) return { ok: false, reason: 'canceled', error: __('Sign-in was canceled.') };
// Checked after the wait rather than before it, so a code that expires
// mid-sleep is reported as expired instead of being polled once more.
if (now() >= expiresAt) {
- return { ok: false, reason: 'expired', error: 'The code expired before it was entered.' };
+ return { ok: false, reason: 'expired', error: __('The code expired before it was entered.') };
}
let res;
@@ -198,14 +201,15 @@ async function pollForToken({ deviceCode, interval, expiresAt }, deps = {}) {
waitSeconds = Number(json.interval) > 0 ? Number(json.interval) : waitSeconds + SLOW_DOWN_BUMP_SECONDS;
break;
case 'expired_token':
- return { ok: false, reason: 'expired', error: 'The code expired before it was entered.' };
+ return { ok: false, reason: 'expired', error: __('The code expired before it was entered.') };
case 'access_denied':
- return { ok: false, reason: 'denied', error: 'The authorization was declined on GitHub.' };
+ return { ok: false, reason: 'denied', error: __('The authorization was declined on GitHub.') };
default:
return {
ok: false,
reason: 'error',
- error: json.error_description || json.error || `GitHub returned ${res.status}`
+ // translators: %s: an HTTP status code, such as 500.
+ error: json.error_description || json.error || sprintf(__('GitHub returned %s'), res.status)
};
}
}
@@ -252,10 +256,11 @@ async function fetchViewer(token, deps = {}) {
return { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) };
}
if (res.status === 401) {
- return { ok: false, reason: 'unauthorized', error: 'That sign-in is no longer valid.' };
+ return { ok: false, reason: 'unauthorized', error: __('That sign-in is no longer valid.') };
}
if (res.status !== 200 || !res.json || !res.json.login) {
- return { ok: false, reason: 'error', error: `GitHub returned ${res.status}` };
+ // translators: %s: an HTTP status code, such as 500.
+ return { ok: false, reason: 'error', error: sprintf(__('GitHub returned %s'), res.status) };
}
// Only judged when GitHub states the scopes: the header is how OAuth
// tokens carry them, and its absence (some token types omit it) is not
@@ -265,7 +270,12 @@ async function fetchViewer(token, deps = {}) {
return {
ok: false,
reason: 'insufficient-scope',
- error: 'GitHub granted this sign-in less access than opening a pull request needs — an older authorization of this app was likely reused. Revoke "WordPress Contributor Toolkit" under github.com → Settings → Applications, then sign in here again.'
+ error: sprintf(
+ // translators: 1: the name of this app's GitHub application, WordPress Contributor Toolkit. 2: where to revoke it, github.com → Settings → Applications.
+ __('GitHub granted this sign-in less access than opening a pull request needs — an older authorization of this app was likely reused. Revoke "%1$s" under %2$s, then sign in here again.'),
+ 'WordPress Contributor Toolkit',
+ 'github.com → Settings → Applications'
+ )
};
}
return { ok: true, login: String(res.json.login) };
diff --git a/src/github-pr.cjs b/src/github-pr.cjs
index 0f17a3bf..8f42409b 100644
--- a/src/github-pr.cjs
+++ b/src/github-pr.cjs
@@ -32,6 +32,7 @@
* patch file, which is always still there — but not the same explanation.
*/
+const { __, _n, sprintf } = require('@wordpress/i18n');
const { getJson, postJson } = require('./github-http.cjs');
const { classifyHttpFailure } = require('./patch-sources.cjs');
const { ticketUrl } = require('./renderer/trac-ticket.cjs');
@@ -159,8 +160,22 @@ function classifyFailure(res) {
* @return {{ok: false, reason: string, error: string}}
*/
function failure(res, what) {
- const detail = res.json && res.json.message ? res.json.message : `GitHub returned ${res.status}`;
- return { ok: false, reason: classifyFailure(res), error: `${what}: ${detail}${describeResponse(res)}` };
+ let detail = res.json && res.json.message;
+ if (!detail) {
+ // translators: %s: an HTTP status code, such as 500.
+ detail = sprintf(__('GitHub returned %s'), res.status);
+ }
+ return {
+ ok: false,
+ reason: classifyFailure(res),
+ error: sprintf(
+ // translators: 1: what failed, such as "Could not create the branch". 2: GitHub's reason, which is in English. 3: technical details in square brackets, for GitHub support.
+ __('%1$s: %2$s%3$s'),
+ what,
+ detail,
+ describeResponse(res)
+ )
+ };
}
/**
@@ -276,7 +291,12 @@ async function ensureFork({ token, login }, deps = {}) {
const notAFork = () => ({
ok: false,
reason: 'error',
- error: `You already have a repository named ${up.repo} that is not a fork of ${up.owner}/${up.repo}, so there is nowhere to push this. The patch file still works.`
+ error: sprintf(
+ // translators: 1: a repository name, such as wordpress-develop. 2: the repository it should be a fork of, such as WordPress/wordpress-develop.
+ __('You already have a repository named %1$s that is not a fork of %2$s, so there is nowhere to push this. The patch file still works.'),
+ up.repo,
+ `${up.owner}/${up.repo}`
+ )
});
// Ready means the fork's own trunk ref answers — not that the repo
@@ -295,7 +315,7 @@ async function ensureFork({ token, login }, deps = {}) {
return { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) };
}
if (existing.status === 200 && !isOurFork(existing.json)) return notAFork();
- if (existing.status !== 200 && existing.status !== 404) return failure(existing, 'Could not check for your fork');
+ if (existing.status !== 200 && existing.status !== 404) return failure(existing, __('Could not check for your fork'));
let created = false;
if (existing.status === 404) {
@@ -305,7 +325,11 @@ async function ensureFork({ token, login }, deps = {}) {
} catch (e) {
return { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) };
}
- if (forked.status !== 202 && forked.status !== 200) return failure(forked, `Could not fork ${up.repo}`);
+ if (forked.status !== 202 && forked.status !== 200) return failure(forked, sprintf(
+ // translators: %s: a repository name, such as wordpress-develop.
+ __('Could not fork %s'),
+ up.repo
+ ));
created = true;
}
@@ -319,7 +343,7 @@ async function ensureFork({ token, login }, deps = {}) {
// 404 is the fork still initialising, the state this loop exists to
// wait out. Anything else — a token revoked mid-wait, a spent rate
// limit — will not resolve by waiting, so it is reported as itself.
- if (ref.status !== 404) return failure(ref, 'Could not read your fork');
+ if (ref.status !== 404) return failure(ref, __('Could not read your fork'));
await wait(FORK_POLL_INTERVAL_MS);
}
} catch (e) {
@@ -329,7 +353,7 @@ async function ensureFork({ token, login }, deps = {}) {
return {
ok: false,
reason: 'error',
- error: 'Your fork is still being set up on GitHub — for a repository this size that can take a few minutes. Try again shortly; the patch file still works.'
+ error: __('Your fork is still being set up on GitHub — for a repository this size that can take a few minutes. Try again shortly; the patch file still works.')
};
}
@@ -374,7 +398,11 @@ async function resolveBase({ token, login, baseSha }, deps = {}) {
const ref = await get(`${repo}/git/ref/heads/${base}`, { token });
if (ref.status !== 200 || !ref.json || !ref.json.object || !ref.json.object.sha) {
- return failure(ref, `Could not read your fork’s ${base}`);
+ return failure(ref, sprintf(
+ // translators: %s: a branch name, such as trunk.
+ __('Could not read your fork’s %s'),
+ base
+ ));
}
const tip = String(ref.json.object.sha);
return { ok: true, sha: tip, exact: tip === baseSha };
@@ -475,13 +503,17 @@ async function createTree({ token, login, baseTreeSha, files }, deps = {}) {
encoding: 'base64'
}, { token });
if (blob.status !== 201 || !blob.json || !blob.json.sha) {
- return failure(blob, `Could not upload ${file.path}`);
+ return failure(blob, sprintf(
+ // translators: %s: the path of a file in the change.
+ __('Could not upload %s'),
+ file.path
+ ));
}
entries.push({ path: file.path, mode: file.mode || DEFAULT_MODE, type: 'blob', sha: blob.json.sha });
}
const tree = await post(`${repo}/git/trees`, { base_tree: baseTreeSha, tree: entries }, { token });
- if (tree.status !== 201 || !tree.json || !tree.json.sha) return failure(tree, 'Could not assemble the change');
+ if (tree.status !== 201 || !tree.json || !tree.json.sha) return failure(tree, __('Could not assemble the change'));
return { ok: true, sha: String(tree.json.sha) };
} catch (e) {
return { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) };
@@ -514,7 +546,7 @@ async function commitAndBranch({ token, login, ticketId, message, treeSha, paren
tree: treeSha,
parents: [parentSha]
}, { token });
- if (commit.status !== 201 || !commit.json || !commit.json.sha) return failure(commit, 'Could not create the commit');
+ if (commit.status !== 201 || !commit.json || !commit.json.sha) return failure(commit, __('Could not create the commit'));
const sha = String(commit.json.sha);
let lastRes = null;
@@ -530,16 +562,20 @@ async function commitAndBranch({ token, login, ticketId, message, treeSha, paren
return {
ok: false,
reason: 'error',
- error: `Your fork is still being set up on GitHub. Try again in a minute — the patch file still works.${describeResponse(ref)}`
+ error: sprintf(
+ // translators: %s: technical details in square brackets, for GitHub support.
+ __('Your fork is still being set up on GitHub. Try again in a minute — the patch file still works.%s'),
+ describeResponse(ref)
+ )
};
}
// 422 is how GitHub says the reference already exists — the only
// status worth another name. Anything else is a real failure and
// retrying it nine more times would only slow down the report.
- if (ref.status !== 422) return failure(ref, 'Could not create the branch');
+ if (ref.status !== 422) return failure(ref, __('Could not create the branch'));
lastRes = ref;
}
- return failure(lastRes, 'Could not find an unused branch name');
+ return failure(lastRes, __('Could not find an unused branch name'));
} catch (e) {
return { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) };
}
@@ -568,7 +604,7 @@ async function createPullRequest({ token, login, branch, title, body }, deps = {
base: baseBranchFor(deps.project),
maintainer_can_modify: true
}, { token });
- if (res.status !== 201 || !res.json || !res.json.html_url) return failure(res, 'Could not open the pull request');
+ if (res.status !== 201 || !res.json || !res.json.html_url) return failure(res, __('Could not open the pull request'));
return { ok: true, url: String(res.json.html_url), number: Number(res.json.number) };
} catch (e) {
return { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) };
@@ -609,7 +645,7 @@ async function openPullRequest({ token, login, ticketId, baseSha, files, title,
const at = (stage, result) => ({ ...result, stage });
if (!files || files.length === 0) {
- return { ok: false, reason: 'empty', error: 'There are no changes to open a pull request with.', stage: 'collect' };
+ return { ok: false, reason: 'empty', error: __('There are no changes to open a pull request with.'), stage: 'collect' };
}
report('forking');
@@ -633,7 +669,15 @@ async function openPullRequest({ token, login, ticketId, baseSha, files, title,
return {
ok: false,
reason: 'stale',
- error: `Trunk has moved under ${stale.clashes.length === 1 ? 'a file you edited' : 'files you edited'} (${stale.clashes.slice(0, 3).join(', ')}${stale.clashes.length > 3 ? ', …' : ''}). Update this site to the latest trunk, check your changes still apply, and try again.`,
+ error: sprintf(
+ // translators: %s: up to three file paths, separated by commas.
+ _n(
+ 'Trunk has moved under a file you edited (%s). Update this site to the latest trunk, check your changes still apply, and try again.',
+ 'Trunk has moved under files you edited (%s). Update this site to the latest trunk, check your changes still apply, and try again.',
+ stale.clashes.length
+ ),
+ stale.clashes.slice(0, 3).join(', ') + (stale.clashes.length > 3 ? ', …' : '')
+ ),
stage: 'syncing'
};
}
@@ -649,7 +693,7 @@ async function openPullRequest({ token, login, ticketId, baseSha, files, title,
return at('syncing', { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) });
}
if (baseCommit.status !== 200 || !baseCommit.json || !baseCommit.json.tree) {
- return at('syncing', failure(baseCommit, 'Could not read the base commit'));
+ return at('syncing', failure(baseCommit, __('Could not read the base commit')));
}
report('committing');
diff --git a/src/main.js b/src/main.js
index c0730cde..d3b5424e 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1291,7 +1291,7 @@ ipcMain.handle('github:sign-in', async (event) => {
ipcMain.handle('github:open-pr', async (event, sitePath, options = {}) => {
if (!githubToken || !githubLogin) {
- return { ok: false, reason: 'unauthorized', error: 'Sign in to GitHub first.', stage: 'auth' };
+ return { ok: false, reason: 'unauthorized', error: __('Sign in to GitHub first.'), stage: 'auth' };
}
// The ticket is read from this site's stored metadata rather than taken
@@ -1307,7 +1307,7 @@ ipcMain.handle('github:open-pr', async (event, sitePath, options = {}) => {
const project = projectTypeForSite(meta);
const ticketId = meta.tracTicket;
if (!ticketId) {
- return { ok: false, reason: 'no-ticket', error: `Link a ${project.workItem.label} to this site first. A pull request has to cite one.`, stage: 'auth' };
+ return { ok: false, reason: 'no-ticket', error: project.cards.prNeedsWorkItem, stage: 'auth' };
}
const ownershipRefusal = await appliedPatchSubmissionRefusal(sitePath);
if (ownershipRefusal) return { ...ownershipRefusal, stage: 'ownership' };
diff --git a/src/project-type.cjs b/src/project-type.cjs
index 77f33bdd..bd40beb7 100644
--- a/src/project-type.cjs
+++ b/src/project-type.cjs
@@ -84,30 +84,35 @@ const PROJECT_TYPES = {
cards: {
// What the pull-request destination says when there is no work item
// linked to cite.
- prBlockedNote: 'No ticket is linked to this site. A pull request has to cite one — link it in the Trac card.',
+ get prBlockedNote() { return __('No ticket is linked to this site. A pull request has to cite one — link it in the Trac card.'); },
// The rest of the pull-request destination's words that differ per
// target: what it costs, what happens after, what the app cannot do
// for a signed-out contributor, the help under the notes field, the
// line that sends them back to the work item once the pull request
// exists, and the fold that says how pull requests work here.
- prCost: 'A GitHub account. The fork is made for you; no password is typed into this app and no credential is written to disk.',
- prAfter: 'Automated checks run on it. Nobody watches GitHub, though — posting the link on the ticket is what gets it seen.',
- signInCannot: 'It cannot create the GitHub account for you, and it cannot post to Trac on your behalf.',
- prNotesHelp: 'Goes at the top of the description. The ticket link and your WordPress.org username are added underneath.',
- prLoopBack: 'Triage and props live on the ticket, so the link belongs there too.',
+ get prCost() { return __('A GitHub account. The fork is made for you; no password is typed into this app and no credential is written to disk.'); },
+ get prAfter() { return __('Automated checks run on it. Nobody watches GitHub, though — posting the link on the ticket is what gets it seen.'); },
+ get signInCannot() { return __('It cannot create the GitHub account for you, and it cannot post to Trac on your behalf.'); },
+ get prNotesHelp() { return __('Goes at the top of the description. The ticket link and your WordPress.org username are added underneath.'); },
+ get prLoopBack() { return __('Triage and props live on the ticket, so the link belongs there too.'); },
// Two facts from the core handbook that a first-timer has no way to
// know and that change what they do next: nobody is watching GitHub,
// and nothing is merged there. Both make the Trac step this flow
// ends on the point rather than the postscript.
- prHow: {
- summary: 'How pull requests work in core',
- lines: [
- 'Nobody watches the pull request list. Yours is seen because its link is on the ticket — which is why this flow ends by sending you back there.',
- 'Nothing is merged on GitHub either. A committer applies the change themselves, and the ticket is where they decide to.'
- ],
- linkLabel: 'The handbook page on pull requests',
- linkUrl: 'https://make.wordpress.org/core/handbook/contribute/git/github-pull-requests-for-code-review/'
+ get prHow() {
+ return {
+ summary: __('How pull requests work in core'),
+ lines: [
+ __('Nobody watches the pull request list. Yours is seen because its link is on the ticket — which is why this flow ends by sending you back there.'),
+ __('Nothing is merged on GitHub either. A committer applies the change themselves, and the ticket is where they decide to.')
+ ],
+ linkLabel: __('The handbook page on pull requests'),
+ linkUrl: 'https://make.wordpress.org/core/handbook/contribute/git/github-pull-requests-for-code-review/'
+ };
},
+ // Main's refusal to open a pull request with nothing linked, for a
+ // caller that skipped the card's own note above.
+ get prNeedsWorkItem() { return __('Link a Trac ticket to this site first. A pull request has to cite one.'); },
// Where a contributor edits, named by the hint under the terminal
// ("Edited files in src/? Run npm run build"). Core's source is
// src/; Gutenberg's is its packages (#490).
@@ -184,24 +189,27 @@ const PROJECT_TYPES = {
},
cards: {
- prBlockedNote: 'No issue is linked to this site. A pull request has to cite one: link it in the GitHub issue card.',
- prCost: 'A GitHub account. The fork is made for you; no password is typed into this app and no credential is written to disk.',
- prAfter: 'Automated checks run on it, and it is reviewed and merged right there: on Gutenberg the pull request is the venue.',
- signInCannot: 'It cannot create the GitHub account for you.',
- prNotesHelp: 'Goes at the top of the description. The Fixes line that links the issue and your WordPress.org username are added underneath.',
- prLoopBack: 'The Fixes line already lists it on the issue. A comment there still tells the people watching it.',
+ get prBlockedNote() { return __('No issue is linked to this site. A pull request has to cite one: link it in the GitHub issue card.'); },
+ get prCost() { return __('A GitHub account. The fork is made for you; no password is typed into this app and no credential is written to disk.'); },
+ get prAfter() { return __('Automated checks run on it, and it is reviewed and merged right there: on Gutenberg the pull request is the venue.'); },
+ get signInCannot() { return __('It cannot create the GitHub account for you.'); },
+ get prNotesHelp() { return __('Goes at the top of the description. The Fixes line that links the issue and your WordPress.org username are added underneath.'); },
+ get prLoopBack() { return __('The Fixes line already lists it on the issue. A comment there still tells the people watching it.'); },
// The two facts above are false here, and the audience least able
// to spot the app describing a different project is exactly this
// one, so the fold says what is true of Gutenberg instead.
- prHow: {
- summary: 'How pull requests work in Gutenberg',
- lines: [
- 'The pull request is where the change is reviewed and, once approved, merged. Nothing has to be posted anywhere else for it to be seen.',
- 'Reviewers ask for testing steps. Put them in the notes: what to open, what to click, what should happen.'
- ],
- linkLabel: 'The Gutenberg contributing guide',
- linkUrl: 'https://github.com/WordPress/gutenberg/blob/trunk/CONTRIBUTING.md'
+ get prHow() {
+ return {
+ summary: __('How pull requests work in Gutenberg'),
+ lines: [
+ __('The pull request is where the change is reviewed and, once approved, merged. Nothing has to be posted anywhere else for it to be seen.'),
+ __('Reviewers ask for testing steps. Put them in the notes: what to open, what to click, what should happen.')
+ ],
+ linkLabel: __('The Gutenberg contributing guide'),
+ linkUrl: 'https://github.com/WordPress/gutenberg/blob/trunk/CONTRIBUTING.md'
+ };
},
+ get prNeedsWorkItem() { return __('Link a GitHub issue to this site first. A pull request has to cite one.'); },
sourceDir: 'packages/',
patchFiles: false
},
diff --git a/src/renderer/components/pull-request-destination.jsx b/src/renderer/components/pull-request-destination.jsx
index 31cd1fce..d00ff49f 100644
--- a/src/renderer/components/pull-request-destination.jsx
+++ b/src/renderer/components/pull-request-destination.jsx
@@ -1,4 +1,6 @@
import { Button, Spinner, TextControl, TextareaControl } from '@wordpress/components';
+import { createInterpolateElement } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
import { Notice, Stack, Text } from '@wordpress/ui';
import { copy as copyIcon, check as checkIcon } from '@wordpress/icons';
import { prStageLabel } from '../pr-stage.cjs';
@@ -8,12 +10,15 @@ import { Destination } from './destination.jsx';
// these still leaves the patch file, which is what the card offers underneath.
// The no-ticket refusal is not here: the main process words it for the site's
// work item (#251), and the fallback below shows that sentence as sent.
-const PR_FAILURE_MESSAGES = {
- unauthorized: 'That GitHub sign-in is no longer valid. Sign in again, or save the patch file instead.',
- 'rate-limited': 'GitHub is rate-limiting this connection. It usually clears within the hour.',
- offline: 'No connection to GitHub.',
- empty: 'There are no changes to open a pull request with.'
-};
+function prFailureMessage(reason) {
+ switch (reason) {
+ case 'unauthorized': return __('That GitHub sign-in is no longer valid. Sign in again, or save the patch file instead.');
+ case 'rate-limited': return __('GitHub is rate-limiting this connection. It usually clears within the hour.');
+ case 'offline': return __('No connection to GitHub.');
+ case 'empty': return __('There are no changes to open a pull request with.');
+ default: return null;
+ }
+}
// A notice here is not also spoken: the card it is in is being read.
const SILENT = '';
@@ -53,12 +58,25 @@ export function PullRequestDestination({ pr, project, workItem, ticket, refusal,
*/}
{pr.result.dryRun ? (
{pr.result.branch}.
+ {createInterpolateElement(
+ sprintf(
+ // translators: %s: the number of the pull request, inside a link to it.
{account.testMode.target}, not to {upstreamPath}.>}
+ ? __('Dry run — a branch is pushed to your fork, no pull request is opened.')
+ : createInterpolateElement(
+ // translators: {account.testMode.target}, upstream: <>{upstreamPath}> }
+ )}