diff --git a/content/copilot/how-tos/copilot-on-github/set-up-copilot/enable-copilot/set-up-for-teachers-and-os-maintainers.md b/content/copilot/how-tos/copilot-on-github/set-up-copilot/enable-copilot/set-up-for-teachers-and-os-maintainers.md index e56e808cdbe0..6af900c28b50 100644 --- a/content/copilot/how-tos/copilot-on-github/set-up-copilot/enable-copilot/set-up-for-teachers-and-os-maintainers.md +++ b/content/copilot/how-tos/copilot-on-github/set-up-copilot/enable-copilot/set-up-for-teachers-and-os-maintainers.md @@ -26,6 +26,8 @@ There are two ways to qualify for free access to {% data variables.copilot.copil {% data variables.product.company_short %} reevaluates your eligibility every month. +{% data reusables.copilot.free-access-cancellation-note %} For more information about managing your plan, see [AUTOTITLE](/copilot/how-tos/manage-your-account/view-and-change-your-copilot-plan). + ## What if I don't qualify for free access to {% data variables.copilot.copilot_pro_short %}? If you do not meet the previous criteria, you can do one of the following: diff --git a/content/copilot/how-tos/manage-your-account/view-and-change-your-copilot-plan.md b/content/copilot/how-tos/manage-your-account/view-and-change-your-copilot-plan.md index b00816ee8614..49b1ed86c225 100644 --- a/content/copilot/how-tos/manage-your-account/view-and-change-your-copilot-plan.md +++ b/content/copilot/how-tos/manage-your-account/view-and-change-your-copilot-plan.md @@ -65,7 +65,7 @@ You can upgrade or downgrade your {% data variables.product.prodname_copilot_sho You can cancel your {% data variables.product.prodname_copilot_short %} plan at any time. You’ll retain access to your current features until your billing cycle ends. You will automatically be downgraded to {% data variables.copilot.copilot_free_short %} at the end of your billing cycle. -If you have been granted free access to {% data variables.product.prodname_copilot_short %} as a verified student, teacher, or maintainer of a popular open source project, you won’t be able to cancel your plan. If you have access to {% data variables.product.prodname_copilot_short %} through an organization or enterprise, you will not be able to cancel your plan. In these cases, you can disable {% data variables.product.prodname_copilot_short %} in your environment. See [AUTOTITLE](/copilot/how-tos/configure-personal-settings/configure-in-ide). +{% data reusables.copilot.free-access-cancellation-note %} If you have access to {% data variables.product.prodname_copilot_short %} through an organization or enterprise, you will not be able to cancel your plan. In these cases, you can disable {% data variables.product.prodname_copilot_short %} in your environment. See [AUTOTITLE](/copilot/how-tos/configure-personal-settings/configure-in-ide). {% data reusables.copilot.copilot-one-account-short %} diff --git a/content/copilot/reference/copilot-usage-metrics/copilot-usage-metrics.md b/content/copilot/reference/copilot-usage-metrics/copilot-usage-metrics.md index 8204cedaa531..8d71e2188000 100644 --- a/content/copilot/reference/copilot-usage-metrics/copilot-usage-metrics.md +++ b/content/copilot/reference/copilot-usage-metrics/copilot-usage-metrics.md @@ -158,7 +158,7 @@ Active-user counts: | `weekly_passive_copilot_code_review_users` | `integer` | No | Number of unique users who had {% data variables.copilot.copilot_code-review_short %} automatically assigned to review their pull request during a trailing seven-day window, with no active engagement. | | `monthly_passive_copilot_code_review_users` | `integer` | No | Number of unique users who had {% data variables.copilot.copilot_code-review_short %} automatically assigned to review their pull request during a trailing 28-day window, with no active engagement. | | `daily_active_cli_users` | `integer` | Yes | Number of unique users who used {% data variables.copilot.copilot_cli_short %} on a given day. This count is **independent** of IDE active-user counts and is **not** included in IDE-based active-user definitions. Omitted for enterprises or organizations with no CLI usage that day. | -| `daily_active_copilot_app_users` | `integer` | Yes | Number of unique users who used the {% data variables.copilot.github_copilot_app_short %} on a given day. Enterprise reports only. Null when the enterprise has no {% data variables.copilot.github_copilot_app_short %} activity that day. | +| `daily_active_copilot_app_users` | `integer` | Yes | Number of unique users who used the {% data variables.copilot.github_copilot_app_short %} on a given day. Null when the enterprise or organization has no {% data variables.copilot.github_copilot_app_short %} activity that day. | Activity totals and breakdowns: @@ -177,7 +177,7 @@ Activity totals and breakdowns: | `totals_by_language_model` | `array` | No | Aggregated language-and-model activity breakdown. See [Activity breakdown objects](#activity-breakdown-objects). | | `totals_by_model_feature` | `array` | No | Aggregated model-and-feature activity breakdown. See [Activity breakdown objects](#activity-breakdown-objects). | | `totals_by_cli` | `object` | Yes | Aggregated {% data variables.copilot.copilot_cli_short %} metrics for the day. Omitted when there is no CLI usage that day. Unlike the per-user form, it does not include `last_known_cli_version`. See [{% data variables.copilot.copilot_cli_short %} metrics fields](#copilot-cli-metrics-fields). | -| `totals_by_copilot_app` | `object` | Yes | Aggregated {% data variables.copilot.github_copilot_app_short %} metrics for the day. Enterprise reports only. Null when the enterprise has no {% data variables.copilot.github_copilot_app_short %} activity that day. See [{% data variables.copilot.github_copilot_app_short %} metrics fields](#copilot-app-metrics-fields). | +| `totals_by_copilot_app` | `object` | Yes | Aggregated {% data variables.copilot.github_copilot_app_short %} metrics for the day. Null when the enterprise or organization has no {% data variables.copilot.github_copilot_app_short %} activity that day. See [{% data variables.copilot.github_copilot_app_short %} metrics fields](#copilot-app-metrics-fields). | | `totals_by_3rd_party_agent` | `array` | Yes | Aggregated per-agent usage metrics for recognized {% data variables.copilot.agent_apps %}. Omitted when the enterprise or organization had no recognized {% data variables.copilot.agent_app %} activity that day. See [{% data variables.copilot.agent_apps_caps %} metrics fields](#agent-apps-metrics-fields). | | `totals_by_ai_adoption_phase` | `array` | Yes | Per-phase aggregates of users and their average activity. Omitted when no adoption-phase data is available. See [AI adoption phase fields](#ai-adoption-phase-fields). | | `pull_requests` | `object` | No | Daily pull request activity for the enterprise or organization. See [Pull request activity fields](#pull-request-activity-fields). | @@ -366,6 +366,8 @@ Each entry in the aggregated `totals_by_ai_adoption_phase` array contains: | `avg_pull_requests_merged` | `number` | No | Average pull requests merged per user in this phase. | | `total_pull_requests_merged` | `integer` | No | Total pull requests merged by users in this phase. Applies to the organization day and enterprise day aggregated reports. | | `avg_pull_requests_median_minutes_to_merge` | `number` | No | Average of the per-user median minutes to merge for users in this phase. | +| `avg_pull_requests_minutes_to_review` | `number` | No | Median time in minutes from pull request creation to first review, scoped to merged pull requests and attributed to the pull request's merge day. | +| `avg_pull_requests_review_cycles` | `number` | No | Median number of review submissions (review cycles) per pull request, scoped to merged pull requests and attributed to the pull request's merge day. | ### Breakdown dimension values diff --git a/content/copilot/reference/copilot-usage-metrics/example-schema.md b/content/copilot/reference/copilot-usage-metrics/example-schema.md index e3f34669b38c..b7778e3aee68 100644 --- a/content/copilot/reference/copilot-usage-metrics/example-schema.md +++ b/content/copilot/reference/copilot-usage-metrics/example-schema.md @@ -19,6 +19,11 @@ The following are example schemas for the user-level, enterprise-level, and repo ```json copy [{ + "ai_adoption_phase": { + "phase": "Phase 2", + "phase_number": 2, + "version": "v1" + }, "ai_credits_used": 12.5, "code_acceptance_activity_count": 3, "code_generation_activity_count": 3, @@ -124,6 +129,10 @@ The following are example schemas for the user-level, enterprise-level, and repo "used_chat": false, "used_cli": true, "used_copilot_app": true, + "used_copilot_cloud_agent": false, + "used_copilot_code_review_active": null, + "used_copilot_code_review_passive": null, + "used_copilot_coding_agent": false, "user_id": 1, "user_login": "login1", "user_initiated_interaction_count": 1, @@ -135,6 +144,8 @@ The following are example schemas for the user-level, enterprise-level, and repo Organization per-user reports use the same `totals_by_3rd_party_agent` entry fields and also include `organization_id`. Per-user entries do not include `session_count`. +In this example, `used_copilot_code_review_active` and `used_copilot_code_review_passive` are `null` because there was no {% data variables.copilot.copilot_code-review_short %} signal for the user that day. `used_copilot_cloud_agent` and `used_copilot_coding_agent` always carry the same value. + ## Enterprise-level schema example ```json copy @@ -144,7 +155,10 @@ Organization per-user reports use the same `totals_by_3rd_party_agent` entry fie "code_generation_activity_count" : 4, "daily_active_cli_users" : 2, "daily_active_copilot_app_users" : 1, + "daily_active_copilot_cloud_agent_users" : 1, + "daily_active_copilot_code_review_users" : 1, "daily_active_users" : 2, + "daily_passive_copilot_code_review_users" : 0, "day" : "2025-10-01", "enterprise_id" : "1", "loc_added_sum" : 54, @@ -153,7 +167,10 @@ Organization per-user reports use the same `totals_by_3rd_party_agent` entry fie "loc_suggested_to_delete_sum" : 6, "monthly_active_agent_users" : 0, "monthly_active_chat_users" : 0, + "monthly_active_copilot_cloud_agent_users" : 1, + "monthly_active_copilot_code_review_users" : 1, "monthly_active_users" : 2, + "monthly_passive_copilot_code_review_users" : 0, "pull_requests" : { "median_minutes_to_merge" : 2.5, "median_minutes_to_merge_copilot_authored" : 2.5, @@ -181,6 +198,39 @@ Organization per-user reports use the same `totals_by_3rd_party_agent` entry fie "session_count" : 2, "user_initiated_interaction_count" : 2 } ], + "totals_by_ai_adoption_phase" : [ { + "avg_code_acceptance_activities" : 1.0, + "avg_code_generation_activities" : 1.0, + "avg_loc_added" : 22.0, + "avg_loc_deleted" : 0.0, + "avg_pull_requests_created" : 1.0, + "avg_pull_requests_median_minutes_to_merge" : 2.5, + "avg_pull_requests_merged" : 1.0, + "avg_pull_requests_minutes_to_review" : 1.5, + "avg_pull_requests_review_cycles" : 1.0, + "avg_pull_requests_reviewed" : 0.0, + "avg_user_initiated_interactions" : 0.0, + "phase" : "Phase 1", + "phase_number" : 1, + "total_engaged_users" : 1, + "total_pull_requests_merged" : 1 + }, { + "avg_code_acceptance_activities" : 3.0, + "avg_code_generation_activities" : 3.0, + "avg_loc_added" : 32.0, + "avg_loc_deleted" : 6.0, + "avg_pull_requests_created" : 1.0, + "avg_pull_requests_median_minutes_to_merge" : 2.5, + "avg_pull_requests_merged" : 1.0, + "avg_pull_requests_minutes_to_review" : 2.0, + "avg_pull_requests_review_cycles" : 2.0, + "avg_pull_requests_reviewed" : 1.0, + "avg_user_initiated_interactions" : 1.0, + "phase" : "Phase 2", + "phase_number" : 2, + "total_engaged_users" : 1, + "total_pull_requests_merged" : 1 + } ], "totals_by_cli" : { "prompt_count" : 3, "request_count" : 3, @@ -252,7 +302,10 @@ Organization per-user reports use the same `totals_by_3rd_party_agent` entry fie "totals_by_language_model" : [ ], "totals_by_model_feature" : [ ], "user_initiated_interaction_count" : 1, - "weekly_active_users" : 2 + "weekly_active_copilot_cloud_agent_users" : 1, + "weekly_active_copilot_code_review_users" : 1, + "weekly_active_users" : 2, + "weekly_passive_copilot_code_review_users" : 0 } ], "enterprise_id" : "1", "report_end_day" : "2025-10-01", @@ -264,7 +317,10 @@ Organization per-user reports use the same `totals_by_3rd_party_agent` entry fie "day_totals" : [ { "code_acceptance_activity_count" : 2, "code_generation_activity_count" : 3, + "daily_active_copilot_cloud_agent_users" : 0, + "daily_active_copilot_code_review_users" : 0, "daily_active_users" : 2, + "daily_passive_copilot_code_review_users" : 0, "day" : "2025-10-01", "enterprise_id" : "2", "loc_added_sum" : 50, @@ -273,7 +329,10 @@ Organization per-user reports use the same `totals_by_3rd_party_agent` entry fie "loc_suggested_to_delete_sum" : 3, "monthly_active_agent_users" : 0, "monthly_active_chat_users" : 0, + "monthly_active_copilot_cloud_agent_users" : 0, + "monthly_active_copilot_code_review_users" : 0, "monthly_active_users" : 2, + "monthly_passive_copilot_code_review_users" : 0, "pull_requests" : { "total_applied_suggestions" : 0, "total_copilot_applied_suggestions" : 0, @@ -287,6 +346,39 @@ Organization per-user reports use the same `totals_by_3rd_party_agent` entry fie "total_reviewed_by_copilot" : 0, "total_suggestions" : 1 }, + "totals_by_ai_adoption_phase" : [ { + "avg_code_acceptance_activities" : 0.0, + "avg_code_generation_activities" : 1.0, + "avg_loc_added" : 12.0, + "avg_loc_deleted" : 3.0, + "avg_pull_requests_created" : 0.0, + "avg_pull_requests_median_minutes_to_merge" : 0.0, + "avg_pull_requests_merged" : 0.0, + "avg_pull_requests_minutes_to_review" : 0.0, + "avg_pull_requests_review_cycles" : 0.0, + "avg_pull_requests_reviewed" : 0.0, + "avg_user_initiated_interactions" : 0.0, + "phase" : "No Cohort", + "phase_number" : 0, + "total_engaged_users" : 1, + "total_pull_requests_merged" : 0 + }, { + "avg_code_acceptance_activities" : 2.0, + "avg_code_generation_activities" : 2.0, + "avg_loc_added" : 38.0, + "avg_loc_deleted" : 0.0, + "avg_pull_requests_created" : 1.0, + "avg_pull_requests_median_minutes_to_merge" : 0.0, + "avg_pull_requests_merged" : 0.0, + "avg_pull_requests_minutes_to_review" : 0.0, + "avg_pull_requests_review_cycles" : 0.0, + "avg_pull_requests_reviewed" : 1.0, + "avg_user_initiated_interactions" : 1.0, + "phase" : "Phase 1", + "phase_number" : 1, + "total_engaged_users" : 1, + "total_pull_requests_merged" : 0 + } ], "totals_by_feature" : [ { "code_acceptance_activity_count" : 1, "code_generation_activity_count" : 2, @@ -338,7 +430,10 @@ Organization per-user reports use the same `totals_by_3rd_party_agent` entry fie "totals_by_language_model" : [ ], "totals_by_model_feature" : [ ], "user_initiated_interaction_count" : 1, - "weekly_active_users" : 2 + "weekly_active_copilot_cloud_agent_users" : 0, + "weekly_active_copilot_code_review_users" : 0, + "weekly_active_users" : 2, + "weekly_passive_copilot_code_review_users" : 0 } ], "enterprise_id" : "2", "report_end_day" : "2025-10-01", diff --git a/content/migrations/elm/migrated-data-reference.md b/content/migrations/elm/migrated-data-reference.md index d7229df384de..bf08b9d9143e 100644 --- a/content/migrations/elm/migrated-data-reference.md +++ b/content/migrations/elm/migrated-data-reference.md @@ -27,9 +27,12 @@ The only organization-level action supported by {% data variables.product.prodna | Repository PR settings | Pull request merge and review settings | | Actions settings | Repository-level GitHub Actions configuration, including allowed actions and permissions | | Autolinks | Custom autolink references | -| Branch protections | Branch protection rules | +| Branch protections | Branch protection rules are partially migrated, with the limitations described below | | Pages | GitHub Pages configuration | +> [!WARNING] +> Branch protection migration is partially supported. {% data variables.product.prodname_elm_short %} preserves whether actor restrictions are enabled for dismissing pull request reviews and pushing to protected branches, but it does not preserve the permitted users, teams, or apps. A push restriction can therefore be recreated on the destination with an empty allowlist. It also does not migrate pull request bypass actors, force-push actors, status check app bindings, matching-branch creation restrictions, merge queue settings, deployment requirements, or branch locking. Review and update migrated branch protection rules before allowing users to work in the destination repository. + ### Git data, LFS, and wikis | Resource | Notes | @@ -96,7 +99,7 @@ The only organization-level action supported by {% data variables.product.prodna | Data | Notes | |---|---| | Repository defaults | N/A | -| Repository rulesets | Only branch protection rules are migrated. | +| Repository rulesets | Repository rulesets are not migrated. Branch protection rules are partially migrated. | | Pull requests from forks | N/A | | Pending pull request reviews | Only submitted reviews are migrated. | @@ -127,7 +130,7 @@ The following tables document which resource types support live updates and whic | **Label** | `created` | `deleted`, `edited` | | **Release** | `created`, `edited`, `prereleased`, `published`, `released`, `unpublished` | `deleted` | | **Milestone** | `closed`, `created`, `edited`, `opened` | `deleted` | -| **Branch protection rule** | `created`, `edited` | `deleted` | +| **Branch protection rule (partial support)** | `created`, `edited` | `deleted` | | **Commit comment** | `created`, `edited` | `deleted` | | **Page build** | All actions | N/A | | **Commit status checks** | All actions | N/A | @@ -138,6 +141,7 @@ The following tables document which resource types support live updates and whic | **Repository actions settings** | `updated` | N/A | | **Repository webhook** | `created`, `updated` | N/A | +The branch protection limitations described in [Repository settings and configuration](#repository-settings-and-configuration) apply to both the initial migration and live updates. > [!NOTE] > When a pull request review is submitted, {% data variables.product.prodname_elm_short %} imports the full review including all inline comments together. Inline comments that are part of an initial review submission are migrated correctly. However, **replies added to a review thread after the review has been submitted** are not captured as live updates and will not appear in the migrated repository. diff --git a/data/reusables/copilot/free-access-cancellation-note.md b/data/reusables/copilot/free-access-cancellation-note.md new file mode 100644 index 000000000000..75c0946b950a --- /dev/null +++ b/data/reusables/copilot/free-access-cancellation-note.md @@ -0,0 +1 @@ +If you have been granted free access to {% data variables.product.prodname_copilot_short %} as a verified student, teacher, or maintainer of a popular open source project, you won’t be able to cancel your plan. diff --git a/data/tables/supported-code-languages.yml b/data/tables/supported-code-languages.yml index 8d1e252ca939..1670833085ef 100644 --- a/data/tables/supported-code-languages.yml +++ b/data/tables/supported-code-languages.yml @@ -41,7 +41,7 @@ features: languages: C: copilot: 'supported' - codeNavigation: 'not-supported' + codeNavigation: 'supported' codeScanning: 'supported' depGraph: 'not-supported' depUpdates: 'not-supported' @@ -49,7 +49,7 @@ languages: packages: 'not-supported' C++: copilot: 'supported' - codeNavigation: 'not-supported' + codeNavigation: 'supported' codeScanning: 'supported' depGraph: 'not-supported' depUpdates: 'not-supported' @@ -129,7 +129,7 @@ languages: packages: 'not-supported' Scala: copilot: 'supported' - codeNavigation: 'not-supported' + codeNavigation: 'supported' codeScanning: 'third-party [^1]' depGraph: 'Maven' depUpdates: 'Maven, Gradle' diff --git a/src/links/lib/update-internal-links.ts b/src/links/lib/update-internal-links.ts index 27b63dd7caec..216e1dfeb468 100644 --- a/src/links/lib/update-internal-links.ts +++ b/src/links/lib/update-internal-links.ts @@ -701,3 +701,26 @@ function singleStartingQuote(text: string) { function isSimpleQuote(text: string) { return text.startsWith('"') && text.endsWith('"') && text.split('"').length === 3 } +/** + * Write a Markdown page back out, preserving the original frontmatter text verbatim + * whenever the frontmatter data itself didn't change. + * + * Round-tripping frontmatter through the YAML serializer reflows values that were + * never touched: long `intro` strings become block scalars, `redirect_from` entries get + * rewrapped, and quote styles change. That churn dwarfs the actual link fixes and makes + * a bulk run unreviewable, which is why this only reserializes when it has to. + */ +export function serializeMarkdown( + rawContent: string, + content: string, + newContent: string, + newData: Record | undefined, + differentData: boolean, +): string { + // `content` is the tail of the file, so everything before it is the frontmatter + // block exactly as the author wrote it, delimiters and all. + if (!differentData && rawContent.endsWith(content)) { + return rawContent.slice(0, rawContent.length - content.length) + newContent + } + return frontmatter.stringify(newContent, newData || {}) +} diff --git a/src/links/scripts/update-internal-links.ts b/src/links/scripts/update-internal-links.ts index c1d23109d8d2..2b923a9bd1bf 100755 --- a/src/links/scripts/update-internal-links.ts +++ b/src/links/scripts/update-internal-links.ts @@ -14,8 +14,7 @@ import { program } from 'commander' import chalk from 'chalk' import { dump } from 'js-yaml' -import { updateInternalLinks } from '@/links/lib/update-internal-links' -import frontmatter from '@/frame/lib/read-frontmatter' +import { updateInternalLinks, serializeMarkdown } from '@/links/lib/update-internal-links' import walkFiles from '@/workflows/walk-files' program @@ -158,7 +157,11 @@ async function main(files: string[], opts: Options) { } else { // Remember the `content` and `newContent` is the "meat" of the // Markdown page. To save it you need the frontmatter data too. - fs.writeFileSync(file, frontmatter.stringify(newContent || '', newData || {}), 'utf-8') + fs.writeFileSync( + file, + serializeMarkdown(rawContent, content, newContent, newData, differentData), + 'utf-8', + ) } } } diff --git a/src/links/tests/update-internal-links.ts b/src/links/tests/update-internal-links.ts new file mode 100644 index 000000000000..d2e369186d53 --- /dev/null +++ b/src/links/tests/update-internal-links.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from 'vitest' + +import frontmatter from '@/frame/lib/read-frontmatter' +import { serializeMarkdown } from '@/links/lib/update-internal-links' + +const PAGE = `--- +title: About merge methods +intro: 'You can allow contributors with push access to merge their pull requests on {% data variables.product.prodname_dotcom %} with different merge options.' +redirect_from: + - /articles/about-merge-methods-on-github + - /github/administering-a-repository/about-merge-methods-on-github +versions: + fpt: '*' + ghes: '*' +--- + +Body text with a [link](/en/old-path). +` + +// `frontmatter()` only omits `content` when the YAML fails to parse, which none of +// these fixtures do. Narrow it once here so each test can stay readable. +function parse(raw: string): { content: string; data: Record } { + const { content, data } = frontmatter(raw) + if (content === undefined) throw new Error('fixture failed to parse') + return { content, data: data || {} } +} + +// The whole fix rests on gray-matter's `content` being an exact suffix of the raw file. +// Test that against the real parser, not a hand-rolled stand-in. +describe('frontmatter parse invariant', () => { + const cases: [string, string][] = [ + ['standard page', PAGE], + ['CRLF line endings', PAGE.replace(/\n/g, '\r\n')], + ['byte order mark', `\uFEFF${PAGE}`], + ['no frontmatter', 'Just a body with a [link](/en/old-path).\n'], + ['empty frontmatter', '---\n---\n\nBody.\n'], + ['thematic break in body', '---\ntitle: T\n---\n\nBefore.\n\n---\n\nAfter.\n'], + ['empty body', '---\ntitle: T\n---\n'], + ['no trailing newline', '---\ntitle: T\n---\n\nBody.'], + ] + + test.each(cases)('%s: content is a suffix of rawContent', (_name, raw) => { + const { content } = parse(raw) + + expect(raw.endsWith(content)).toBe(true) + }) + + test.each(cases)('%s: round-trips unchanged when nothing changed', (_name, raw) => { + const { content, data } = parse(raw) + + expect(serializeMarkdown(raw, content, content, data, false)).toBe(raw) + }) + + test.each(cases)('%s: preserves the frontmatter block byte-for-byte', (_name, raw) => { + const { content, data } = parse(raw) + const newContent = content.replace('/en/old-path', '/en/new-path') + + const result = serializeMarkdown(raw, content, newContent, data, false) + + const originalFrontmatter = raw.slice(0, raw.length - content.length) + expect(result).toBe(originalFrontmatter + newContent) + }) +}) + +describe('serializeMarkdown', () => { + test('rewrites the body without reflowing untouched YAML', () => { + const { content, data } = parse(PAGE) + const newContent = content.replace('/en/old-path', '/en/new-path') + + const result = serializeMarkdown(PAGE, content, newContent, data, false) + + expect(result).toContain('/en/new-path') + expect(result).not.toContain('/en/old-path') + // The single-quoted intro and the list-style redirect_from must survive untouched. + expect(result).toContain( + "intro: 'You can allow contributors with push access to merge their pull requests", + ) + expect(result).toContain(' - /articles/about-merge-methods-on-github') + }) + + test('reserializes when the frontmatter data actually changed', () => { + const { content } = parse(PAGE) + + const result = serializeMarkdown( + PAGE, + content, + content, + { title: 'About merge methods', redirect_from: ['/new-redirect'] }, + true, + ) + + expect(result).toContain('/new-redirect') + expect(result).not.toContain('/articles/about-merge-methods-on-github') + expect(result).toContain('Body text with a [link](/en/old-path).') + }) + + test('falls back to reserializing if content is not a clean tail of the raw file', () => { + const result = serializeMarkdown( + PAGE, + 'not a suffix of the raw file', + 'New body.', + { title: 'Fallback' }, + false, + ) + + expect(result).toContain('title: Fallback') + expect(result).toContain('New body.') + }) + + test('is idempotent across repeated runs', () => { + const { content, data } = parse(PAGE) + const newContent = content.replace('/en/old-path', '/en/new-path') + + const once = serializeMarkdown(PAGE, content, newContent, data, false) + const twice = serializeMarkdown(once, parse(once).content, newContent, data, false) + + expect(twice).toBe(once) + }) +})