Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
be7ee96
chore(go): update go-sdk seed (#13521)
fern-support Mar 13, 2026
27ac417
fix(csharp): Improve gRPC code generation (#13520)
amckinney Mar 13, 2026
34065b3
fix(csharp): `RetriesTest.cs` uses correct JSON serialization formatt…
patrickthornton Mar 13, 2026
6d21168
chore(csharp): update csharp-sdk seed (#13522)
fern-support Mar 13, 2026
8a555b4
feat(csharp): generate separate WireMock server per test fixture and …
Swimburger Mar 13, 2026
85557d6
feat(csharp): rework enum serialization to eliminate reflection (#13519)
Swimburger Mar 13, 2026
de4ed4e
chore(csharp): update csharp-sdk seed (#13523)
fern-support Mar 13, 2026
2efb1de
feat(csharp): add `sln-format` config option for legacy .sln solution…
patrickthornton Mar 13, 2026
155f62e
chore(csharp): update csharp-model seed (#13526)
fern-support Mar 13, 2026
4197f92
chore(csharp): update csharp-sdk seed (#13527)
fern-support Mar 13, 2026
bc159e7
feat(ci): Turn on stale-bot for PRs (#13340)
davidkonigsberg Mar 13, 2026
791d1a2
chore(csharp): update csharp-sdk seed (#13528)
fern-support Mar 13, 2026
ac1f89e
chore(csharp): update csharp-model seed (#13529)
fern-support Mar 13, 2026
192bcaf
feat(parser): `coerce-consts-to` coerces `const`s to either `enums` (…
patrickthornton Mar 13, 2026
35aa1fe
fix(go): ensure deterministic ordering in generated requests.go and r…
developerkunal Mar 13, 2026
9d05fa6
fix(java): Enable forward-compatible enums; stop extra props (#13524)
iamnamananand996 Mar 13, 2026
f4a1866
chore(go): update go-model seed (#13535)
fern-support Mar 13, 2026
480424c
chore(go): update go-sdk seed (#13536)
fern-support Mar 13, 2026
1c77f88
chore(go): update go-sdk seed (#13537)
fern-support Mar 13, 2026
8316061
chore(go): update go-model seed (#13538)
fern-support Mar 13, 2026
dc57ed7
chore(deps): update yauzl to 3.2.1 to address CVE-2026-31988 (#13533)
github-actions[bot] Mar 13, 2026
432047f
fix(ci): use squash merge for dependabot auto-merge (#13541)
davidkonigsberg Mar 13, 2026
787a40e
chore(deps): add pnpm override for yauzl ^3.2.1 to resolve Dependabot…
devin-ai-integration[bot] Mar 13, 2026
388afdc
chore(deps): update flatted to >=3.4.0 to fix CVE-2026-32141 (#13534)
github-actions[bot] Mar 13, 2026
48f2c8c
chore(deps): bump undici from 6.23.0 to 6.24.0 (#13540)
dependabot[bot] Mar 13, 2026
3c1bab7
chore(csharp): update csharp-sdk seed (#13532)
fern-support Mar 13, 2026
4b8e7f5
feat(typescript): add passthrough fetch method to generated SDK clien…
Swimburger Mar 13, 2026
f8c7af1
feat(cli): add AI changelog rollup with version_bump_reason field (#1…
Swimburger Mar 13, 2026
253fc65
chore(typescript): update ts-sdk seed (#13544)
fern-support Mar 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
2 changes: 1 addition & 1 deletion .github/workflows/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Enable auto-merge for Dependabot PRs
if: ${{contains(steps.metadata.outputs.update-type, 'version-update')}}
run: gh pr merge --auto --merge "$PR_URL"
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
Expand Down
11 changes: 7 additions & 4 deletions .github/workflows/stale-bot.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: "Cleanup stale PRs and Branches"
name: "Cleanup stale PRs, Branches, and Issues"
on:
schedule:
- cron: "30 1 * * *"
Expand All @@ -7,20 +7,23 @@ on:
permissions:
pull-requests: write
contents: write
issues: write

env:
DO_NOT_TRACK: "1"

jobs:
stale-prs:
stale-prs-and-issues:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
with:
stale-issue-message: "This issue is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 30 days."
close-issue-message: "This issue was closed because it has been inactive for 30 days after being marked stale."
days-before-issue-stale: 180
days-before-issue-close: 30
stale-pr-message: "This PR is stale because it has been open 25 days with no activity. Remove stale label or comment or this will be closed in 5 days."
close-pr-message: "This PR was closed because it has been inactive for 5 days after being marked stale."
days-before-issue-stale: -1
days-before-issue-close: -1
days-before-pr-stale: 25
days-before-pr-close: 5
operations-per-run: 500
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,15 @@ public CallCredentials? CallCredentials {
}

/// <summary>
/// Headers to be sent with this particular request.
/// Additional headers to be sent with this particular request.
/// Headers with matching keys will be overwritten by headers set on the client options.
/// </summary>
internal Headers Headers {
public IEnumerable<KeyValuePair<string, string?>> AdditionalHeaders {
get;
#if NET5_0_OR_GREATER
init;
#else
set;
#endif
} = new();
} = new List<KeyValuePair<string, string?>>();
}
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ public void ToDeepObject_WithArrayOfObjects_ReturnsDeepObjectNotation()
}

// Test helper types - defined inline to avoid dependency on generated types
[JsonConverter(typeof(EnumSerializer<TestEnum>))]
[JsonConverter(typeof(TestEnumSerializer))]
private enum TestEnum
{
[EnumMember(Value = "value_1")]
Expand All @@ -148,7 +148,31 @@ private enum TestEnum
Value2
}

[JsonConverter(typeof(StringEnumSerializer<TestStringEnum>))]
private class TestEnumSerializer : JsonConverter<TestEnum>
{
public override TestEnum Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, JsonSerializerOptions options)
{
var stringValue = reader.GetString() ?? throw new System.Exception("The JSON value could not be read as a string.");
return stringValue switch
{
"value_1" => TestEnum.Value1,
"value_2" => TestEnum.Value2,
_ => default
};
}

public override void Write(System.Text.Json.Utf8JsonWriter writer, TestEnum value, JsonSerializerOptions options)
{
writer.WriteStringValue(value switch
{
TestEnum.Value1 => "value_1",
TestEnum.Value2 => "value_2",
_ => throw new System.ArgumentOutOfRangeException(nameof(value), value, null)
});
}
}

[JsonConverter(typeof(TestStringEnum.TestStringEnumSerializer))]
[System.Serializable]
private readonly record struct TestStringEnum : IStringEnum
{
Expand All @@ -165,6 +189,20 @@ public TestStringEnum(string value)
public bool Equals(string? other) => Value.Equals(other);

public override string ToString() => Value;

internal class TestStringEnumSerializer : JsonConverter<TestStringEnum>
{
public override TestStringEnum Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, JsonSerializerOptions options)
{
var stringValue = reader.GetString() ?? throw new System.Exception("The JSON value could not be read as a string.");
return new TestStringEnum(stringValue);
}

public override void Write(System.Text.Json.Utf8JsonWriter writer, TestStringEnum value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.Value);
}
}
}

private class TestObject
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,38 @@ public class DummyObject
public DummyEnum EnumProperty { get; set; }
}

[JsonConverter(typeof(EnumSerializer<DummyEnum>))]
[JsonConverter(typeof(DummyEnumSerializer))]
public enum DummyEnum
{
[EnumMember(Value = "known_value1")]
KnownValue1,

[EnumMember(Value = "known_value2")]
KnownValue2
}
}

internal class DummyEnumSerializer : JsonConverter<DummyEnum>
{
private static readonly Dictionary<string, DummyEnum> _stringToEnum = new()
{
{ "known_value1", DummyEnum.KnownValue1 },
{ "known_value2", DummyEnum.KnownValue2 },
};

private static readonly Dictionary<DummyEnum, string> _enumToString = new()
{
{ DummyEnum.KnownValue1, "known_value1" },
{ DummyEnum.KnownValue2, "known_value2" },
};

public override DummyEnum Read(ref System.Text.Json.Utf8JsonReader reader, global::System.Type typeToConvert, JsonSerializerOptions options)
{
var stringValue = reader.GetString() ?? throw new global::System.Exception("The JSON value could not be read as a string.");
return _stringToEnum.TryGetValue(stringValue, out var enumValue) ? enumValue : default;
}

public override void Write(System.Text.Json.Utf8JsonWriter writer, DummyEnum value, JsonSerializerOptions options)
{
writer.WriteStringValue(_enumToString.TryGetValue(value, out var stringValue) ? stringValue : null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public class DummyObject
public DummyEnum EnumProperty { get; set; }
}

[JsonConverter(typeof(StringEnumSerializer<DummyEnum>))]
[JsonConverter(typeof(DummyEnum.DummyEnumSerializer))]
public readonly record struct DummyEnum : IStringEnum
{
public DummyEnum(string value)
Expand Down Expand Up @@ -135,4 +135,18 @@ public override int GetHashCode()
public static bool operator ==(DummyEnum value1, string value2) => value1.Value.Equals(value2);

public static bool operator !=(DummyEnum value1, string value2) => !value1.Value.Equals(value2);

internal class DummyEnumSerializer : JsonConverter<DummyEnum>
{
public override DummyEnum Read(ref System.Text.Json.Utf8JsonReader reader, global::System.Type typeToConvert, JsonSerializerOptions options)
{
var stringValue = reader.GetString() ?? throw new global::System.Exception("The JSON value could not be read as a string.");
return new DummyEnum(stringValue);
}

public override void Write(System.Text.Json.Utf8JsonWriter writer, DummyEnum value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.Value);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using global::System.Net.Http;
using global::System.Text.Json;
using SystemTask = global::System.Threading.Tasks.Task;
using WireMock.Server;
using WireMockRequest = WireMock.RequestBuilders.Request;
Expand Down Expand Up @@ -321,8 +322,6 @@ public async SystemTask SendRequestAsync_ShouldRespectXRateLimitResetHeader()
[Test]
public async SystemTask SendRequestAsync_ShouldPreserveJsonBody_OnRetry()
{
const string expectedBody = """{"key":"value"}""";

_server
.Given(WireMockRequest.Create().WithPath("/test").UsingPost())
.InScenario("RetryWithBody")
Expand Down Expand Up @@ -352,9 +351,10 @@ public async SystemTask SendRequestAsync_ShouldPreserveJsonBody_OnRetry()
Assert.That(content, Is.EqualTo("Success"));
Assert.That(_server.LogEntries, Has.Count.EqualTo(2));

// Verify the retried request preserved the JSON body
// Verify the retried request preserved the JSON body (compare parsed to ignore formatting differences)
var retriedEntry = _server.LogEntries.ElementAt(1);
Assert.That(retriedEntry.RequestMessage.Body, Is.EqualTo(expectedBody));
using var actualJson = JsonDocument.Parse(retriedEntry.RequestMessage.Body!);
Assert.That(actualJson.RootElement.GetProperty("key").GetString(), Is.EqualTo("value"));
}
}

Expand Down Expand Up @@ -390,9 +390,10 @@ public async SystemTask SendRequestAsync_ShouldPreserveMultipartBody_OnRetry()
Assert.That(content, Is.EqualTo("Success"));
Assert.That(_server.LogEntries, Has.Count.EqualTo(2));

// Verify the retried request preserved the multipart body
// Verify the retried request preserved the multipart body (check key/value presence to ignore formatting differences)
var retriedEntry = _server.LogEntries.ElementAt(1);
Assert.That(retriedEntry.RequestMessage.Body, Does.Contain("""{"key":"value"}"""));
Assert.That(retriedEntry.RequestMessage.Body, Does.Contain("\"key\""));
Assert.That(retriedEntry.RequestMessage.Body, Does.Contain("\"value\""));
}
}

Expand Down
13 changes: 12 additions & 1 deletion generators/csharp/base/src/context/GeneratorContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -840,7 +840,6 @@ export abstract class GeneratorContext extends AbstractGeneratorContext {
// types that can get used
this.Types.ReadOnlyAdditionalProperties();
this.Types.JsonUtils;
this.Types.StringEnumSerializer;
this.Types.IStringEnum;

// start with the models
Expand All @@ -866,6 +865,18 @@ export abstract class GeneratorContext extends AbstractGeneratorContext {
origin: this.model.explicit(typeDeclaration, "Values"),
enclosingType
});

// Register nested serializer class reference
this.csharp.classReference({
origin: this.model.explicit(typeDeclaration, `${enclosingType.name}Serializer`),
enclosingType
});
} else {
// Register companion serializer class reference for regular enums
this.csharp.classReference({
origin: this.model.explicit(typeDeclaration, `${enclosingType.name}Serializer`),
namespace: enclosingType.namespace
});
}
},
object: (otd: ObjectTypeDeclaration) => {
Expand Down
67 changes: 63 additions & 4 deletions generators/csharp/base/src/project/CsharpProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AbstractProject, FernGeneratorExec, File, SourceFetcher } from "@fern-a
import { Generation, WithGeneration } from "@fern-api/csharp-codegen";
import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils";
import { loggingExeca } from "@fern-api/logging-execa";
import { createHash } from "crypto";
import { Eta } from "eta";
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
import path from "path";
Expand Down Expand Up @@ -153,7 +154,7 @@ export class CsharpProject extends AbstractProject<GeneratorContext> {
.replace(/<\/Project>/, `<ItemGroup><Using Include="System" /></ItemGroup></Project>`)
);

// call dotnet format on the solution file using absolute path
// call dotnet format on the solution file using absolute path (always use .slnx for dotnet format)
const solutionFile = join(absolutePathToSolutionDirectory, RelativeFilePath.of(`${this.name}.slnx`));
await loggingExeca(this.context.logger, "dotnet", ["format", solutionFile, "--severity", "error"], {
doNotPipeOutput: false
Expand Down Expand Up @@ -435,8 +436,10 @@ dotnet_diagnostic.IDE0005.severity = error
}

/**
* Generates the .slnx solution file directly as XML, avoiding dotnet CLI overhead.
* Generates the solution file directly as a template, avoiding dotnet CLI overhead.
* Computes relative paths from the solution directory to both project .csproj files.
* When `sln-format` is "sln", generates both .sln and .slnx files.
* When `sln-format` is "slnx" (default), generates only .slnx.
*/
private async createSolutionFile({
absolutePathToSolutionDirectory,
Expand All @@ -456,6 +459,7 @@ dotnet_diagnostic.IDE0005.severity = error
RelativeFilePath.of(`${testProjectName}.csproj`)
);

// Always generate .slnx format
const libraryCsprojRelative = path
.relative(absolutePathToSolutionDirectory, libraryCsprojAbsolute)
.replace(/\\/g, "/");
Expand All @@ -469,8 +473,53 @@ dotnet_diagnostic.IDE0005.severity = error
</Solution>
`;

const solutionFilePath = join(absolutePathToSolutionDirectory, RelativeFilePath.of(`${this.name}.slnx`));
await writeFile(solutionFilePath, slnxContents);
const slnxFilePath = join(absolutePathToSolutionDirectory, RelativeFilePath.of(`${this.name}.slnx`));
await writeFile(slnxFilePath, slnxContents);

// When sln-format is "sln", also generate the legacy .sln file
if (this.settings.slnFormat === "sln") {
const libraryCsprojRelativeBackslash = path
.relative(absolutePathToSolutionDirectory, libraryCsprojAbsolute)
.replace(/\//g, "\\");
const testCsprojRelativeBackslash = path
.relative(absolutePathToSolutionDirectory, testCsprojAbsolute)
.replace(/\//g, "\\");

const projectTypeGuid = "FAE04EC0-301F-11D3-BF4B-00C04F79EFBC";
const libraryProjectGuid = generateDeterministicGuid(this.name);
const testProjectGuid = generateDeterministicGuid(testProjectName);

const slnContents = [
"Microsoft Visual Studio Solution File, Format Version 12.00",
"# Visual Studio Version 17",
"VisualStudioVersion = 17.0.31903.59",
"MinimumVisualStudioVersion = 10.0.40219.1",
`Project("{${projectTypeGuid}}") = "${this.name}", "${libraryCsprojRelativeBackslash}", "{${libraryProjectGuid}}"`,
"EndProject",
`Project("{${projectTypeGuid}}") = "${testProjectName}", "${testCsprojRelativeBackslash}", "{${testProjectGuid}}"`,
"EndProject",
"Global",
"\tGlobalSection(SolutionConfigurationPlatforms) = preSolution",
"\t\tDebug|Any CPU = Debug|Any CPU",
"\t\tRelease|Any CPU = Release|Any CPU",
"\tEndGlobalSection",
"\tGlobalSection(ProjectConfigurationPlatforms) = postSolution",
`\t\t{${libraryProjectGuid}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU`,
`\t\t{${libraryProjectGuid}}.Debug|Any CPU.Build.0 = Debug|Any CPU`,
`\t\t{${libraryProjectGuid}}.Release|Any CPU.ActiveCfg = Release|Any CPU`,
`\t\t{${libraryProjectGuid}}.Release|Any CPU.Build.0 = Release|Any CPU`,
`\t\t{${testProjectGuid}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU`,
`\t\t{${testProjectGuid}}.Debug|Any CPU.Build.0 = Debug|Any CPU`,
`\t\t{${testProjectGuid}}.Release|Any CPU.ActiveCfg = Release|Any CPU`,
`\t\t{${testProjectGuid}}.Release|Any CPU.Build.0 = Release|Any CPU`,
"\tEndGlobalSection",
"EndGlobal",
""
].join("\n");

const slnFilePath = join(absolutePathToSolutionDirectory, RelativeFilePath.of(`${this.name}.sln`));
await writeFile(slnFilePath, slnContents);
}
}

private async createCoreDirectory({
Expand Down Expand Up @@ -706,6 +755,16 @@ function getAsIsFilepath(filename: string): string {
return AbsoluteFilePath.of(path.join(__dirname, "asIs", filename));
}

/**
* Generates a deterministic GUID from a project name using MD5 hashing.
* This ensures the same project name always produces the same GUID,
* making .sln files reproducible across generation runs.
*/
function generateDeterministicGuid(name: string): string {
const hash = createHash("md5").update(name).digest("hex");
return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`.toUpperCase();
}

declare namespace CsProj {
interface Args {
name: string;
Expand Down
Loading
Loading