Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
344 changes: 324 additions & 20 deletions build/cli/index.js

Large diffs are not rendered by default.

176 changes: 156 additions & 20 deletions build/github_action/index.js

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/issues/branch-synchronization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Detect parent-branch drift on every push and safely align issue or

# Branch synchronization

Copilot can watch parent-to-child branch relationships and notify the related open issue when its working branch falls behind. Detection is intentionally separate from the normal commit workflow: `copilot_branch_sync.yml` listens to pushes on **all branches**, performs only GitHub metadata and comparison requests, and never loads Bugbot or an agent.
Copilot can watch parent-to-child branch relationships and notify the related open issue when its working branch falls behind. Detection is intentionally separate from the normal commit workflow: `copilot_branch_sync.yml` listens to pushes on **all branches**, performs GitHub metadata and comparison requests, and never loads Bugbot or a code-changing agent. English and Spanish notices use bundled catalogs. Another configured BCP-47 locale may make one schema-constrained language request for the complete branch-notice catalog; invalid or unavailable localization falls back atomically to English before any comment is written.

This separation keeps pushes to `main`, `master`, `develop`, release branches, and intermediate feature branches observable without paying the cost or accepting the noise of the full commit-analysis pipeline.

Expand All @@ -21,6 +21,8 @@ On each non-deletion push, the observer:

The notice is stateful. Further parent pushes update it instead of creating comment spam. When the working branch catches up—including after an automated synchronization—the same notice is marked resolved. A PAT-authored sync push is observed too, so a chain such as `develop → feature/A → feature/B` can propagate recommendations to the next level.

Notice prose uses the issue locale inherited from the repository locale unless `issues-locale` overrides it. Commands, branch names, comparison URLs, markers, and machine-readable state remain unchanged in every language.

<Info>
A manually created linked branch needs an open PR whose head is that branch to establish its parent. Branches created by Copilot already persist `parentBranch` and `workingBranch`, so they do not require this fallback.
</Info>
Expand Down
6 changes: 3 additions & 3 deletions specs/CATALOG.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions specs/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
"src/data/model/locale.ts",
"src/actions/github_action_locale_inputs.ts",
"src/application/ports/message_catalog_ports.ts",
"src/application/policies/resolved_message_catalog_policy.ts",
"src/application/policies/branch_sync_message_catalog.ts",
"src/application/usecases/localization/resolve_message_catalog_use_case.ts",
"src/application/usecases/steps/common/comment_language_translation_workflow.ts",
"src/application/policies/comment_translation_policy.ts",
Expand All @@ -45,6 +47,7 @@
"src/architecture/github_publication_mutation_baseline.json",
"src/application/policies/action_summary_policy.ts",
"src/application/policies/branch_sync_notification_policy.ts",
"src/application/usecases/actions/observe_branch_sync_use_case.ts",
"src/application/policies/bugbot_review_presentation_policy.ts",
"src/application/policies/deployment_presentation_policy.ts",
"src/application/usecases/actions/recommend_steps_workflow.ts",
Expand All @@ -65,6 +68,7 @@
"src/application/policies/__tests__/semantic_result_publication_policy.test.ts",
"src/application/policies/__tests__/action_summary_policy.test.ts",
"src/application/policies/__tests__/branch_sync_notification_policy.test.ts",
"src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts",
"src/application/policies/__tests__/bugbot_review_presentation_policy.test.ts",
"src/application/policies/__tests__/deployment_presentation_policy.test.ts",
"src/application/usecases/steps/common/__tests__/comment_language_translation_workflow.test.ts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ import {
isStaleBranchSyncComment,
selectBranchDependenciesForPush,
} from "../branch_sync_notification_policy";
import { ResolveMessageCatalogUseCase } from '../../usecases/localization/resolve_message_catalog_use_case';
import {
BRANCH_SYNC_MESSAGE_IDS,
ENGLISH_BRANCH_SYNC_DEFINITION,
resolveBranchSyncCatalog,
resolveStaticBranchSyncCatalog,
SPANISH_BRANCH_SYNC_DEFINITION,
} from '../branch_sync_message_catalog';

const english = resolveStaticBranchSyncCatalog('en-US');
const spanish = resolveStaticBranchSyncCatalog('es-MX');

const dependency = {
issueNumber: 42,
Expand Down Expand Up @@ -35,10 +46,11 @@ describe("branch sync notification policy", () => {
});

it("keeps notification state independent for multiple branches on one issue", () => {
const firstBody = buildAlignedBranchSyncComment(dependency);
const firstBody = buildAlignedBranchSyncComment(dependency, english);
const second = { ...dependency, workingBranch: "feature/43" };
const secondBody = buildStaleBranchSyncComment({
owner: "org", repository: "repo", dependency: second, comparison: { aheadBy: 0, behindBy: 1 },
messages: english,
});
const comments = [
{ id: 1, body: firstBody, user: { login: "vypbot" } },
Expand All @@ -54,28 +66,132 @@ describe("branch sync notification policy", () => {
repository: "repo",
dependency,
comparison: { aheadBy: 2, behindBy: 3 },
messages: english,
});
expect(stale).toContain(BRANCH_SYNC_STALE_MARKER);
expect(stale).toContain('topic="branch-sync" target="issue:42"');
expect(stale).toContain("3 commit(s) behind");
expect(stale).toContain("2 commit(s) not present");
expect(stale).toContain("3 commits behind");
expect(stale).toContain("2 commits not present");
expect(stale).toContain("/copilot sync-branch");
expect(stale).toContain("release%2F2.0...feature%2F42-sync");
expect(isStaleBranchSyncComment(stale)).toBe(true);

const aligned = buildAlignedBranchSyncComment(dependency);
const aligned = buildAlignedBranchSyncComment(dependency, english);
expect(aligned).toContain(BRANCH_SYNC_ALIGNED_MARKER);
expect(aligned).toContain("now contains");
expect(isStaleBranchSyncComment(aligned)).toBe(false);
});

it('uses locale-aware singular forms and neutralizes unsafe ref presentation', () => {
const unsafe = { ...dependency, workingBranch: 'feature/`@team' };
const stale = buildStaleBranchSyncComment({
owner: 'org', repository: 'repo', dependency: unsafe,
comparison: { aheadBy: 1, behindBy: 1 }, messages: english,
});
expect(stale).toContain('is 1 commit behind');
expect(stale).toContain('contains 1 commit not present');
expect(stale).not.toContain('`@team');
expect(stale).toContain('@\u200bteam');
});

it('renders the same semantic branch states in Spanish', () => {
const stale = buildStaleBranchSyncComment({
owner: 'org', repository: 'repo', dependency,
comparison: { aheadBy: 1, behindBy: 2 }, locale: 'es-MX',
comparison: { aheadBy: 1, behindBy: 2 }, messages: spanish,
});
expect(stale).toContain('## Acción necesaria: sincroniza la rama');
expect(stale).toContain('Ejecuta `/copilot sync-branch`');
expect(buildAlignedBranchSyncComment(dependency, 'es-ES')).toContain('## Rama sincronizada');
expect(buildAlignedBranchSyncComment(dependency, spanish)).toContain('## Rama sincronizada');
});
});

describe('branch sync message catalog', () => {
const configuration = { provider: 'codex' as const, model: 'language-model' };

it.each([
['en-US', 'en-US', 'Action required: synchronize the branch'],
['en-GB', 'en-US', 'Action required: synchronize the branch'],
['es-ES', 'es-ES', 'Acción necesaria: sincroniza la rama'],
['es-MX', 'es-ES', 'Acción necesaria: sincroniza la rama'],
])('selects bundled catalogs for %s', (requested, resolved, heading) => {
const catalog = resolveStaticBranchSyncCatalog(requested);
expect(catalog.locale).toBe(resolved);
expect(catalog.message('branchSync.stale.heading')).toBe(heading);
});

it('keeps bundled definitions complete and structurally identical', () => {
expect(Object.keys(ENGLISH_BRANCH_SYNC_DEFINITION.messages).sort()).toEqual([...BRANCH_SYNC_MESSAGE_IDS].sort());
expect(Object.keys(SPANISH_BRANCH_SYNC_DEFINITION.messages).sort()).toEqual([...BRANCH_SYNC_MESSAGE_IDS].sort());
expect(Object.isFrozen(ENGLISH_BRANCH_SYNC_DEFINITION.messages)).toBe(true);
expect(Object.isFrozen(SPANISH_BRANCH_SYNC_DEFINITION.messages)).toBe(true);
});

it('uses English for an empty default and for a static unsupported locale', async () => {
expect(resolveStaticBranchSyncCatalog('')).toMatchObject({
requestedLocale: 'en-US', locale: 'en-US', resolutionSource: 'exact',
});
expect(resolveStaticBranchSyncCatalog('ar-SA')).toMatchObject({
requestedLocale: 'ar-SA', locale: 'en-US', resolutionSource: 'fallback',
fallbackReason: 'dynamic-provider-unavailable',
});
await expect(resolveBranchSyncCatalog('', configuration, undefined)).resolves.toMatchObject({
requestedLocale: 'en-US', locale: 'en-US', resolutionSource: 'exact',
});
const resolve = jest.fn().mockResolvedValue({
requestedLocale: 'en-US',
resolvedLocale: 'en-US',
source: 'exact',
messages: ENGLISH_BRANCH_SYNC_DEFINITION.messages,
});
await expect(resolveBranchSyncCatalog('', configuration, { resolve })).resolves.toMatchObject({
requestedLocale: 'en-US', locale: 'en-US', resolutionSource: 'exact',
});
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ targetLocale: 'en-US' }));
});

it('resolves an arbitrary BCP-47 locale through one schema-constrained request', async () => {
const translated = {
'branchSync.stale.heading': 'Synchronisation de branche requise',
'branchSync.stale.behind': {
one: '{workingBranch} a {count} commit de retard sur {parentBranch}.',
other: '{workingBranch} a {count} commits de retard sur {parentBranch}.',
},
'branchSync.stale.ahead': {
one: 'Elle contient aussi {count} commit absent de la branche parente.',
other: 'Elle contient aussi {count} commits absents de la branche parente.',
},
'branchSync.stale.instructions': 'Exécutez {command} dans cette conversation pour intégrer les changements en sécurité.',
'branchSync.stale.compare': 'Comparer les branches',
'branchSync.aligned.heading': 'Branche synchronisée',
'branchSync.aligned.status': '{workingBranch} contient maintenant l’historique actuel de {parentBranch}.',
'branchSync.aligned.resolved': 'La recommandation précédente est résolue.',
} as const;
const query = jest.fn().mockResolvedValue({ targetLocale: 'fr-FR', messages: translated });

const catalog = await resolveBranchSyncCatalog(
'fr-FR',
configuration,
new ResolveMessageCatalogUseCase({ query }),
);

expect(catalog).toMatchObject({ requestedLocale: 'fr-FR', locale: 'fr-FR', resolutionSource: 'dynamic' });
expect(catalog.message('branchSync.stale.heading')).toBe('Synchronisation de branche requise');
expect(query).toHaveBeenCalledTimes(1);
});

it('falls back atomically to English when dynamic output is incomplete', async () => {
const query = jest.fn().mockResolvedValue({
targetLocale: 'ja-JP',
messages: { 'branchSync.stale.heading': 'ブランチの同期が必要です' },
});

const catalog = await resolveBranchSyncCatalog(
'ja-JP',
configuration,
new ResolveMessageCatalogUseCase({ query }),
);

expect(catalog).toMatchObject({ locale: 'en-US', resolutionSource: 'fallback', fallbackReason: 'dynamic-response-invalid' });
expect(catalog.message('branchSync.aligned.heading')).toBe('Branch synchronized');
});
});
102 changes: 102 additions & 0 deletions src/application/policies/branch_sync_message_catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { AgentConfiguration } from '../../domain/agent';
import {
MESSAGE_CATALOG_VERSION,
type CatalogMessage,
type MessageCatalogDefinition,
} from '../../domain/message_catalog';
import type { MessageCatalogResolutionPort } from '../ports/message_catalog_ports';
import {
resolveMessageCatalogView,
resolveStaticMessageCatalogView,
type ResolvedMessageCatalogView,
} from './resolved_message_catalog_policy';

export const BRANCH_SYNC_MESSAGE_IDS = Object.freeze([
'branchSync.stale.heading',
'branchSync.stale.behind',
'branchSync.stale.ahead',
'branchSync.stale.instructions',
'branchSync.stale.compare',
'branchSync.aligned.heading',
'branchSync.aligned.status',
'branchSync.aligned.resolved',
] as const);

export type BranchSyncMessageId = typeof BRANCH_SYNC_MESSAGE_IDS[number];
export type BranchSyncMessageCatalog = ResolvedMessageCatalogView<BranchSyncMessageId>;

const ENGLISH_MESSAGES: Readonly<Record<BranchSyncMessageId, CatalogMessage>> = Object.freeze({
'branchSync.stale.heading': 'Action required: synchronize the branch',
'branchSync.stale.behind': Object.freeze({
one: '{workingBranch} is {count} commit behind its parent branch {parentBranch}.',
other: '{workingBranch} is {count} commits behind its parent branch {parentBranch}.',
}),
'branchSync.stale.ahead': Object.freeze({
one: 'It also contains {count} commit not present in the parent branch.',
other: 'It also contains {count} commits not present in the parent branch.',
}),
'branchSync.stale.instructions': 'Run {command} in this conversation to merge the parent changes safely. If Git reports conflicts, the configured fixer agent can resolve eligible files before the verification commands run.',
'branchSync.stale.compare': 'Compare parent and working branch',
'branchSync.aligned.heading': 'Branch synchronized',
'branchSync.aligned.status': '{workingBranch} now contains the current history of its parent branch {parentBranch}.',
'branchSync.aligned.resolved': 'The previous synchronization recommendation has been resolved.',
});

const SPANISH_MESSAGES: Readonly<Record<BranchSyncMessageId, CatalogMessage>> = Object.freeze({
'branchSync.stale.heading': 'Acción necesaria: sincroniza la rama',
'branchSync.stale.behind': Object.freeze({
one: '{workingBranch} está {count} commit por detrás de su rama padre {parentBranch}.',
other: '{workingBranch} está {count} commits por detrás de su rama padre {parentBranch}.',
}),
'branchSync.stale.ahead': Object.freeze({
one: 'También contiene {count} commit que no está en la rama padre.',
other: 'También contiene {count} commits que no están en la rama padre.',
}),
'branchSync.stale.instructions': 'Ejecuta {command} en esta conversación para integrar de forma segura los cambios de la rama padre. Si Git detecta conflictos, el agente corrector configurado puede resolver los archivos permitidos antes de ejecutar las verificaciones.',
'branchSync.stale.compare': 'Comparar la rama padre y la rama de trabajo',
'branchSync.aligned.heading': 'Rama sincronizada',
'branchSync.aligned.status': '{workingBranch} ya contiene el historial actual de su rama padre {parentBranch}.',
'branchSync.aligned.resolved': 'La recomendación de sincronización anterior está resuelta.',
});

export const ENGLISH_BRANCH_SYNC_DEFINITION: MessageCatalogDefinition<BranchSyncMessageId> = Object.freeze({
version: MESSAGE_CATALOG_VERSION,
locale: 'en-US',
compatibleBaseLanguage: 'en',
messages: ENGLISH_MESSAGES,
});

export const SPANISH_BRANCH_SYNC_DEFINITION: MessageCatalogDefinition<BranchSyncMessageId> = Object.freeze({
version: MESSAGE_CATALOG_VERSION,
locale: 'es-ES',
compatibleBaseLanguage: 'es',
messages: SPANISH_MESSAGES,
});

export const BRANCH_SYNC_CATALOG_DEFINITIONS = Object.freeze([
ENGLISH_BRANCH_SYNC_DEFINITION,
SPANISH_BRANCH_SYNC_DEFINITION,
]);

export function resolveStaticBranchSyncCatalog(locale: string): BranchSyncMessageCatalog {
return resolveStaticMessageCatalogView(
locale,
ENGLISH_BRANCH_SYNC_DEFINITION,
BRANCH_SYNC_CATALOG_DEFINITIONS,
);
}

export async function resolveBranchSyncCatalog(
locale: string,
configuration: Readonly<AgentConfiguration> | undefined,
resolver: MessageCatalogResolutionPort | undefined,
): Promise<BranchSyncMessageCatalog> {
return resolveMessageCatalogView(
locale,
BRANCH_SYNC_MESSAGE_IDS,
ENGLISH_BRANCH_SYNC_DEFINITION,
BRANCH_SYNC_CATALOG_DEFINITIONS,
configuration,
resolver,
);
}
Loading