Skip to content

Commit 2da75c8

Browse files
meorphismeorphis
andauthored
add csharp strategy (#217)
* add csharp strategy * fix * fix --------- Co-authored-by: meorphis <[email protected]>
1 parent 0ced8b9 commit 2da75c8

File tree

4 files changed

+361
-0
lines changed

4 files changed

+361
-0
lines changed

src/factory.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {AlwaysBumpPatch} from './versioning-strategies/always-bump-patch';
3838
import {ServicePackVersioningStrategy} from './versioning-strategies/service-pack';
3939
import {DependencyManifest} from './versioning-strategies/dependency-manifest';
4040
import {BaseStrategyOptions} from './strategies/base';
41+
import {CSharp} from './strategies/csharp';
4142
import {DotnetYoshi} from './strategies/dotnet-yoshi';
4243
import {Java} from './strategies/java';
4344
import {Maven} from './strategies/maven';
@@ -65,6 +66,7 @@ export interface StrategyFactoryOptions extends ReleaserConfig {
6566
}
6667

6768
const releasers: Record<string, ReleaseBuilder> = {
69+
csharp: options => new CSharp(options),
6870
'dotnet-yoshi': options => new DotnetYoshi(options),
6971
go: options => new Go(options),
7072
'go-yoshi': options => new GoYoshi(options),

src/strategies/csharp.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import {BaseStrategy, BuildUpdatesOptions} from './base';
2+
import {Update} from '../update';
3+
import {Changelog} from '../updaters/changelog';
4+
import {CsProj} from '../updaters/dotnet/csproj';
5+
import {GitHubFileContents} from '@google-automations/git-file-utils';
6+
import {FileNotFoundError, MissingRequiredFileError} from '../errors';
7+
8+
export class CSharp extends BaseStrategy {
9+
private csprojContents?: GitHubFileContents;
10+
11+
protected async buildUpdates(
12+
options: BuildUpdatesOptions
13+
): Promise<Update[]> {
14+
const updates: Update[] = [];
15+
const version = options.newVersion;
16+
17+
updates.push({
18+
path: this.addPath(this.changelogPath),
19+
createIfMissing: true,
20+
updater: new Changelog({
21+
version,
22+
changelogEntry: options.changelogEntry,
23+
}),
24+
});
25+
26+
const csprojName = await this.getCsprojName();
27+
updates.push({
28+
path: this.addPath(csprojName),
29+
createIfMissing: false,
30+
cachedFileContents: this.csprojContents,
31+
updater: new CsProj({
32+
version,
33+
}),
34+
});
35+
36+
return updates;
37+
}
38+
39+
async getDefaultPackageName(): Promise<string | undefined> {
40+
const csprojContents = await this.getCsprojContents();
41+
const pkg = this.parseCsprojPackageName(csprojContents.parsedContent);
42+
return pkg;
43+
}
44+
45+
protected normalizeComponent(component: string | undefined): string {
46+
if (!component) {
47+
return '';
48+
}
49+
// Handle namespace-style components (e.g., "Acme.Utilities" -> "Utilities")
50+
return component.includes('.') ? component.split('.').pop()! : component;
51+
}
52+
53+
private async getCsprojName(): Promise<string> {
54+
// First, try to find .csproj files in the path
55+
const files = await this.github.findFilesByGlobAndRef(
56+
'*.csproj',
57+
this.changesBranch,
58+
this.path === '.' ? undefined : this.path
59+
);
60+
61+
if (files.length > 0) {
62+
// Return just the filename, not the full path
63+
const fullPath = files[0];
64+
return fullPath.split('/').pop()!;
65+
}
66+
67+
throw new MissingRequiredFileError(
68+
this.addPath('*.csproj'),
69+
'csharp',
70+
`${this.repository.owner}/${this.repository.repo}#${this.changesBranch}`
71+
);
72+
}
73+
74+
private async getCsprojContents(): Promise<GitHubFileContents> {
75+
if (!this.csprojContents) {
76+
const csprojName = await this.getCsprojName();
77+
const csprojPath = this.addPath(csprojName);
78+
const errMissingFile = new MissingRequiredFileError(
79+
csprojPath,
80+
'csharp',
81+
`${this.repository.owner}/${this.repository.repo}#${this.changesBranch}`
82+
);
83+
try {
84+
this.csprojContents = await this.github.getFileContentsOnBranch(
85+
csprojPath,
86+
this.changesBranch
87+
);
88+
} catch (e) {
89+
if (e instanceof FileNotFoundError) {
90+
throw errMissingFile;
91+
}
92+
throw e;
93+
}
94+
if (!this.csprojContents) {
95+
throw errMissingFile;
96+
}
97+
}
98+
return this.csprojContents;
99+
}
100+
101+
private parseCsprojPackageName(content: string): string | undefined {
102+
// Try PackageId first (preferred for NuGet packages)
103+
const packageIdMatch = content.match(/<PackageId>([^<]+)<\/PackageId>/);
104+
if (packageIdMatch) {
105+
return packageIdMatch[1];
106+
}
107+
108+
// Fall back to AssemblyName
109+
const assemblyNameMatch = content.match(
110+
/<AssemblyName>([^<]+)<\/AssemblyName>/
111+
);
112+
if (assemblyNameMatch) {
113+
return assemblyNameMatch[1];
114+
}
115+
116+
// If neither is specified, try to get from the project file name
117+
// (The csproj name without extension is typically the package name)
118+
return undefined;
119+
}
120+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net6.0</TargetFramework>
5+
<PackageId>Acme.TestProject</PackageId>
6+
<Version>0.123.4</Version>
7+
<AssemblyName>TestProject</AssemblyName>
8+
<RootNamespace>Acme.TestProject</RootNamespace>
9+
<Description>A test project for release-please</Description>
10+
<Authors>Test Author</Authors>
11+
</PropertyGroup>
12+
13+
</Project>

test/strategies/csharp.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import {describe, it, afterEach, beforeEach} from 'mocha';
2+
import {CSharp} from '../../src/strategies/csharp';
3+
import {
4+
buildMockConventionalCommit,
5+
buildGitHubFileContent,
6+
assertHasUpdate,
7+
} from '../helpers';
8+
import nock = require('nock');
9+
import * as sinon from 'sinon';
10+
import {GitHub} from '../../src/github';
11+
import {Version} from '../../src/version';
12+
import {TagName} from '../../src/util/tag-name';
13+
import {expect} from 'chai';
14+
import {Changelog} from '../../src/updaters/changelog';
15+
import {CsProj} from '../../src/updaters/dotnet/csproj';
16+
import * as assert from 'assert';
17+
import {MissingRequiredFileError} from '../../src/errors';
18+
19+
nock.disableNetConnect();
20+
const sandbox = sinon.createSandbox();
21+
const fixturesPath = './test/fixtures/strategies/csharp';
22+
23+
describe('CSharp', () => {
24+
let github: GitHub;
25+
const commits = [
26+
...buildMockConventionalCommit(
27+
'fix(deps): update dependency Newtonsoft.Json to v13.0.1'
28+
),
29+
];
30+
beforeEach(async () => {
31+
github = await GitHub.create({
32+
owner: 'googleapis',
33+
repo: 'csharp-test-repo',
34+
defaultBranch: 'main',
35+
});
36+
});
37+
afterEach(() => {
38+
sandbox.restore();
39+
});
40+
describe('buildReleasePullRequest', () => {
41+
it('returns release PR changes with defaultInitialVersion', async () => {
42+
const expectedVersion = '0.0.1';
43+
const strategy = new CSharp({
44+
targetBranch: 'main',
45+
github,
46+
component: 'Acme.TestProject',
47+
packageName: 'Acme.TestProject',
48+
});
49+
sandbox
50+
.stub(github, 'findFilesByGlobAndRef')
51+
.resolves(['TestProject.csproj']);
52+
const latestRelease = undefined;
53+
const release = await strategy.buildReleasePullRequest({
54+
commits,
55+
latestRelease,
56+
});
57+
expect(release!.version?.toString()).to.eql(expectedVersion);
58+
});
59+
it('builds a release pull request', async () => {
60+
const expectedVersion = '0.123.5';
61+
const strategy = new CSharp({
62+
targetBranch: 'main',
63+
github,
64+
component: 'Acme.TestProject',
65+
packageName: 'Acme.TestProject',
66+
});
67+
sandbox
68+
.stub(github, 'findFilesByGlobAndRef')
69+
.resolves(['TestProject.csproj']);
70+
const latestRelease = {
71+
tag: new TagName(Version.parse('0.123.4'), 'Acme.TestProject'),
72+
sha: 'abc123',
73+
notes: 'some notes',
74+
};
75+
const pullRequest = await strategy.buildReleasePullRequest({
76+
commits,
77+
latestRelease,
78+
});
79+
expect(pullRequest!.version?.toString()).to.eql(expectedVersion);
80+
});
81+
it('detects a default component', async () => {
82+
const expectedVersion = '0.123.5';
83+
const strategy = new CSharp({
84+
targetBranch: 'main',
85+
github,
86+
});
87+
const commits = [
88+
...buildMockConventionalCommit(
89+
'fix(deps): update dependency Newtonsoft.Json to v13.0.1'
90+
),
91+
];
92+
const latestRelease = {
93+
tag: new TagName(Version.parse('0.123.4'), 'TestProject'),
94+
sha: 'abc123',
95+
notes: 'some notes',
96+
};
97+
sandbox
98+
.stub(github, 'findFilesByGlobAndRef')
99+
.resolves(['TestProject.csproj']);
100+
const getFileContentsStub = sandbox.stub(
101+
github,
102+
'getFileContentsOnBranch'
103+
);
104+
getFileContentsStub
105+
.withArgs('TestProject.csproj', 'main')
106+
.resolves(buildGitHubFileContent(fixturesPath, 'TestProject.csproj'));
107+
const pullRequest = await strategy.buildReleasePullRequest({
108+
commits,
109+
latestRelease,
110+
});
111+
expect(pullRequest!.version?.toString()).to.eql(expectedVersion);
112+
});
113+
it('detects a default packageName', async () => {
114+
const expectedVersion = '0.123.5';
115+
const strategy = new CSharp({
116+
targetBranch: 'main',
117+
github,
118+
component: 'TestProject',
119+
});
120+
const commits = [
121+
...buildMockConventionalCommit(
122+
'fix(deps): update dependency Newtonsoft.Json to v13.0.1'
123+
),
124+
];
125+
const latestRelease = {
126+
tag: new TagName(Version.parse('0.123.4'), 'TestProject'),
127+
sha: 'abc123',
128+
notes: 'some notes',
129+
};
130+
sandbox
131+
.stub(github, 'findFilesByGlobAndRef')
132+
.resolves(['TestProject.csproj']);
133+
const getFileContentsStub = sandbox.stub(
134+
github,
135+
'getFileContentsOnBranch'
136+
);
137+
getFileContentsStub
138+
.withArgs('TestProject.csproj', 'main')
139+
.resolves(buildGitHubFileContent(fixturesPath, 'TestProject.csproj'));
140+
const pullRequest = await strategy.buildReleasePullRequest({
141+
commits,
142+
latestRelease,
143+
});
144+
expect(pullRequest!.version?.toString()).to.eql(expectedVersion);
145+
});
146+
it('handles missing csproj file', async () => {
147+
sandbox.stub(github, 'findFilesByGlobAndRef').resolves([]);
148+
const strategy = new CSharp({
149+
targetBranch: 'main',
150+
github,
151+
});
152+
const latestRelease = {
153+
tag: new TagName(Version.parse('0.123.4'), 'TestProject'),
154+
sha: 'abc123',
155+
notes: 'some notes',
156+
};
157+
assert.rejects(async () => {
158+
await strategy.buildReleasePullRequest({commits, latestRelease});
159+
}, MissingRequiredFileError);
160+
});
161+
});
162+
describe('buildUpdates', () => {
163+
it('builds common files', async () => {
164+
const strategy = new CSharp({
165+
targetBranch: 'main',
166+
github,
167+
component: 'Acme.TestProject',
168+
packageName: 'Acme.TestProject',
169+
});
170+
sandbox
171+
.stub(github, 'findFilesByGlobAndRef')
172+
.resolves(['TestProject.csproj']);
173+
const latestRelease = undefined;
174+
const release = await strategy.buildReleasePullRequest({
175+
commits,
176+
latestRelease,
177+
});
178+
const updates = release!.updates;
179+
assertHasUpdate(updates, 'CHANGELOG.md', Changelog);
180+
assertHasUpdate(updates, 'TestProject.csproj', CsProj);
181+
});
182+
});
183+
describe('getDefaultPackageName', () => {
184+
it('reads package name from PackageId', async () => {
185+
const strategy = new CSharp({
186+
targetBranch: 'main',
187+
github,
188+
});
189+
sandbox
190+
.stub(github, 'findFilesByGlobAndRef')
191+
.resolves(['TestProject.csproj']);
192+
sandbox
193+
.stub(github, 'getFileContentsOnBranch')
194+
.withArgs('TestProject.csproj', 'main')
195+
.resolves(buildGitHubFileContent(fixturesPath, 'TestProject.csproj'));
196+
const packageName = await strategy.getDefaultPackageName();
197+
expect(packageName).to.eql('Acme.TestProject');
198+
});
199+
});
200+
describe('normalizeComponent', () => {
201+
it('strips namespace prefix when deriving component from packageName', async () => {
202+
const strategy = new CSharp({
203+
targetBranch: 'main',
204+
github,
205+
packageName: 'Acme.Utilities.Core',
206+
});
207+
sandbox
208+
.stub(github, 'findFilesByGlobAndRef')
209+
.resolves(['TestProject.csproj']);
210+
const component = await strategy.getBranchComponent();
211+
expect(component).to.eql('Core');
212+
});
213+
it('handles packageName without namespace', async () => {
214+
const strategy = new CSharp({
215+
targetBranch: 'main',
216+
github,
217+
packageName: 'TestProject',
218+
});
219+
sandbox
220+
.stub(github, 'findFilesByGlobAndRef')
221+
.resolves(['TestProject.csproj']);
222+
const component = await strategy.getBranchComponent();
223+
expect(component).to.eql('TestProject');
224+
});
225+
});
226+
});

0 commit comments

Comments
 (0)