Skip to content
Open
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
2 changes: 2 additions & 0 deletions lib/modules/datasource/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { DenoDatasource } from './deno/index.ts';
import { DevboxDatasource } from './devbox/index.ts';
import { DockerDatasource } from './docker/index.ts';
import { DotnetVersionDatasource } from './dotnet-version/index.ts';
import { ElmPackageDatasource } from './elm-package/index.ts';
import { EndoflifeDateDatasource } from './endoflife-date/index.ts';
import { FlutterVersionDatasource } from './flutter-version/index.ts';
import { ForgejoReleasesDatasource } from './forgejo-releases/index.ts';
Expand Down Expand Up @@ -108,6 +109,7 @@ api.set(DenoDatasource.id, new DenoDatasource());
api.set(DevboxDatasource.id, new DevboxDatasource());
api.set(DockerDatasource.id, new DockerDatasource());
api.set(DotnetVersionDatasource.id, new DotnetVersionDatasource());
api.set(ElmPackageDatasource.id, new ElmPackageDatasource());
api.set(EndoflifeDateDatasource.id, new EndoflifeDateDatasource());
api.set(FlutterVersionDatasource.id, new FlutterVersionDatasource());
api.set(ForgejoReleasesDatasource.id, new ForgejoReleasesDatasource());
Expand Down
124 changes: 124 additions & 0 deletions lib/modules/datasource/elm-package/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import * as httpMock from '~test/http-mock.ts';
import { EXTERNAL_HOST_ERROR } from '../../../constants/error-messages.ts';
import { getPkgReleases } from '../index.ts';
import { ElmPackageDatasource } from './index.ts';

const body = {
'1.0.0': 1534771622,
'1.0.1': 1542199511,
'1.0.2': 1542317893,
'1.0.3': 1575566216,
'1.0.4': 1575998397,
'1.0.5': 1581794195,
};

const baseUrl = 'https://package.elm-lang.org';

describe('modules/datasource/elm-package/index', () => {
describe('getReleases', () => {
it('returns null for empty result', async () => {
httpMock
.scope(baseUrl)
.get('/packages/elm/nonexistent/releases.json')
.reply(200, {});
expect(
await getPkgReleases({
datasource: ElmPackageDatasource.id,
packageName: 'elm/nonexistent',
}),
).toBeNull();
});

it('returns null for 404', async () => {
httpMock
.scope(baseUrl)
.get('/packages/elm/nonexistent/releases.json')
.reply(404);
expect(
await getPkgReleases({
datasource: ElmPackageDatasource.id,
packageName: 'elm/nonexistent',
}),
).toBeNull();
});

it('throws for 5xx', async () => {
httpMock
.scope(baseUrl)
.get('/packages/elm/core/releases.json')
.reply(502);
await expect(
getPkgReleases({
datasource: ElmPackageDatasource.id,
packageName: 'elm/core',
}),
).rejects.toThrow(EXTERNAL_HOST_ERROR);
});

it('throws for 429', async () => {
httpMock
.scope(baseUrl)
.get('/packages/elm/core/releases.json')
.reply(429);
await expect(
getPkgReleases({
datasource: ElmPackageDatasource.id,
packageName: 'elm/core',
}),
).rejects.toThrow(EXTERNAL_HOST_ERROR);
});

it('returns null for unknown error', async () => {
httpMock
.scope(baseUrl)
.get('/packages/elm/core/releases.json')
.replyWithError('');
expect(
await getPkgReleases({
datasource: ElmPackageDatasource.id,
packageName: 'elm/core',
}),
).toBeNull();
});

it('processes real data', async () => {
httpMock
.scope(baseUrl)
.get('/packages/elm/core/releases.json')
.reply(200, body);
const res = await getPkgReleases({
datasource: ElmPackageDatasource.id,
packageName: 'elm/core',
});
expect(res).toEqual({
registryUrl: 'https://package.elm-lang.org',
releases: [
{ version: '1.0.0', releaseTimestamp: '2018-08-20T13:27:02.000Z' },
{ version: '1.0.1', releaseTimestamp: '2018-11-14T12:45:11.000Z' },
{ version: '1.0.2', releaseTimestamp: '2018-11-15T21:38:13.000Z' },
{ version: '1.0.3', releaseTimestamp: '2019-12-05T17:16:56.000Z' },
{ version: '1.0.4', releaseTimestamp: '2019-12-10T17:19:57.000Z' },
{ version: '1.0.5', releaseTimestamp: '2020-02-15T19:16:35.000Z' },
],
sourceUrl: 'https://github.com/elm/core',
});
});

it('handles package without slash in name', async () => {
httpMock
.scope(baseUrl)
.get('/packages/somepackage/releases.json')
.reply(200, { '1.0.0': 1534771622 });
const res = await getPkgReleases({
datasource: ElmPackageDatasource.id,
packageName: 'somepackage',
});
expect(res).toEqual({
registryUrl: 'https://package.elm-lang.org',
releases: [
{ version: '1.0.0', releaseTimestamp: '2018-08-20T13:27:02.000Z' },
],
});
});
});
});
74 changes: 74 additions & 0 deletions lib/modules/datasource/elm-package/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { logger } from '../../../logger/index.ts';
import { joinUrlParts } from '../../../util/url.ts';
import * as elmVersioning from '../../versioning/elm/index.ts';
import { Datasource } from '../datasource.ts';
import type { GetReleasesConfig, ReleaseResult } from '../types.ts';
import { ElmPackageReleasesSchema } from './types.ts';

export class ElmPackageDatasource extends Datasource {
static readonly id = 'elm-package';

constructor() {
super(ElmPackageDatasource.id);
}

override readonly customRegistrySupport = false;

override readonly defaultRegistryUrls = ['https://package.elm-lang.org'];

override readonly defaultVersioning = elmVersioning.id;

override readonly releaseTimestampSupport = true;
override readonly releaseTimestampNote =
'The release timestamp is determined from the Unix timestamp in the results.';

override readonly sourceUrlSupport = 'package';
override readonly sourceUrlNote =
'The source URL is determined from the package name using the GitHub pattern.';

async getReleases({
packageName,
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
const baseUrl = registryUrl ?? this.defaultRegistryUrls[0];
const pkgUrl = joinUrlParts(
baseUrl,
'packages',
packageName,
'releases.json',
);

const { val: result, err } = await this.http
.getJsonSafe(pkgUrl, ElmPackageReleasesSchema)
.onError((err) => {
logger.debug(
{
url: pkgUrl,
datasource: ElmPackageDatasource.id,
packageName,
err,
},
'Error fetching elm package releases',
);
})
.unwrap();

if (err) {
this.handleGenericErrors(err);
}

/* v8 ignore next 3 -- defensive check, result is null only if schema validation fails */
if (!result) {
return null;
}

// Elm packages must be published from GitHub - the package name IS the GitHub repo path
// (e.g., "elm/core" is published from github.com/elm/core)
// This is enforced by the `elm publish` command
if (packageName.includes('/')) {
result.sourceUrl = `https://github.com/${packageName}`;
}

return result;
}
}
5 changes: 5 additions & 0 deletions lib/modules/datasource/elm-package/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
This datasource uses the [Elm Package Registry API](https://package.elm-lang.org) to fetch versions for published Elm packages.

Elm packages use strict semantic versioning. The datasource fetches release timestamps from the registry's per-package releases endpoint.

By design, all Elm packages must be published from GitHub repositories. The package name is the GitHub repository path (e.g., `elm/core` is published from `github.com/elm/core`). This is enforced by the `elm publish` command, which requires packages to be tagged and pushed to GitHub before publishing. Therefore, the source URL is reliably derived from the package name.
21 changes: 21 additions & 0 deletions lib/modules/datasource/elm-package/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { z } from 'zod/v3';
import { asTimestamp } from '../../../util/timestamp.ts';
import type { ReleaseResult } from '../types.ts';

/**
* Response from package.elm-lang.org/packages/{author}/{package}/releases.json
* Maps version strings to Unix timestamps
*/
export const ElmPackageReleasesSchema = z
.record(z.string(), z.number())
.refine((obj) => Object.keys(obj).length > 0, 'No releases found')
.transform((releases): ReleaseResult => {
return {
releases: Object.entries(releases).map(([version, timestamp]) => ({
version,
releaseTimestamp: asTimestamp(timestamp),
})),
};
});

export type ElmPackageReleases = Record<string, number>;