Skip to content

feat(eslint-plugin): [thenable-in-promise-aggregators] disallow non-Thenables to promise aggregators #8094

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 26 commits into from

Conversation

Tjstretchalot
Copy link

@Tjstretchalot Tjstretchalot commented Dec 18, 2023

PR Checklist

Overview

Adds a new rule, thenable-in-promise-aggregators, which verifies that array arguments of Promise.all, Promise.allSettled, and Promise.race are all Thenable. The purpose behind this PR is discussed in the issue; for me, I often wrap promises to make them cancelable, but it still makes sense to name the object e.g. fooPromise, and when mixed with regular promises (or even without), I will type Promise.race([stdPromise, fooPromise]), which will look correct and appear to behave correctly for a long time, before getting non-responsive if one of the promises is unexpectedly slow.

@typescript-eslint
Copy link
Contributor

Thanks for the PR, @Tjstretchalot!

typescript-eslint is a 100% community driven project, and we are incredibly grateful that you are contributing to that community.

The core maintainers work on this in their personal time, so please understand that it may not be possible for them to review your work immediately.

Thanks again!


🙏 Please, if you or your company is finding typescript-eslint valuable, help us sustain the project by sponsoring it transparently on https://opencollective.com/typescript-eslint.

Copy link

netlify bot commented Dec 18, 2023

Deploy Preview for typescript-eslint ready!

Name Link
🔨 Latest commit ea65212
🔍 Latest deploy log https://app.netlify.com/sites/typescript-eslint/deploys/65bd1424933eaf0007ea4345
😎 Deploy Preview https://deploy-preview-8094--typescript-eslint.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 99 (🟢 up 9 from production)
Accessibility: 100 (no change from production)
Best Practices: 92 (no change from production)
SEO: 98 (no change from production)
PWA: 80 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify site configuration.

Copy link
Member

@auvred auvred left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice start! Thanks for working on it!

Just requesting some changes:

@Tjstretchalot
Copy link
Author

Tjstretchalot commented Dec 20, 2023

I believe I covered all the comments. I left conversations open when there may be ambiguity, but since it's easy to miss (I almost missed 5 due to the hidden comments), I'll summarize still pending thoughts here that I'm seeking advice on the best way to resolve:

    {
      code: `
class Foo extends Promise<number> {}
await Foo.all([0]);
      `,
      errors: [
        {
          line: 3,
          messageId: 'inArray',
        },
      ],
    }
  • I use two different styles for valid/invalid cases because I used await-thenable as a reference and it does that. Can/should I do something differently (now or if I make future PRs)? RESOLVED -> using shortened version

Comment on lines +178 to +188
const elementType = services.getTypeAtLocation(element);
if (isTypeAnyType(elementType) || isTypeUnknownType(elementType)) {
continue;
}

const originalNode = services.esTreeNodeToTSNodeMap.get(element);
if (tsutils.isThenableType(checker, originalNode, elementType)) {
continue;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this logic (if isTypeAnyType(sometype) || isTypeUnknownType(sometype) or tsutils.isThenableType(checker, originalNode, sometype)) is used three times in this rule. Maybe we should move it to the function to avoid duplicated code?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm; I refactored around a bit, though I still have 3 calls like this. However, I don't think something like this would add anything besides some mental overhead:

function isTypeAnyOrUnknownType(type: ts.Type): boolean {
  return isTypeAnyType(type) || isTypeUnknownType(type);
}

For this to be useful from my current perspective there would need to be a more salient name for the method that is a more useful concept than checking for any or unknown and makes the implementation obvious (since it's so short). I looked elsewhere around the codebase and any types are not universally treated the same as unknown types, e.g., I could imagine a flag on this check later, perhaps two flags (one for any and one for unknown), and depending on exactly how those flags work it might make sense to break it out then, but more likely even if you made those flags it would still not be helpful to have a function like this given you would want different errors each which explanations of the flag to use to disable that feature, ect.

Copy link
Member

@Josh-Cena Josh-Cena left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was the decision to create a new rule discussed anywhere? I thought it would be a new option for await-thenable, like promiseAggregators: "all" | "any" | "ignore".

@Tjstretchalot
Copy link
Author

Happy new year!

Rebased onto the current state of the main branch

@Josh-Cena There was no discussion AFAIK, I initially made this as a custom eslint rule for myself and so it was easier to translate it over as its own ESLint rule. I'm impartial to including it directly in await-thenable. The only main question I have is how to handle the defaults for the flags?

Right now it's in what I think is the ideal case: included when enabling strict-typed, not included in recommended, and it can be included in recommended on the next major version.

If we add it as configuration for await-thenable, since that is in recommended, the flags presumably need to default to off. But that would mean it's not included in strict-typed and doesn't have an obvious upgrade path to recommended. I suppose it might be possible to have the flags default change depending on whether you have strict-typed included or not, but that seems... weird?

I imagine this came up any other time typescript-eslint wanted to add flags, so I just need to be pointed in the right direction for how to handle them. It would be a little annoying for my use-case, though certainly not the end of the world, if I had to remember to specifically enable these flags on every project to make use of the rule, especially since I would likely only notice it's disabled after the issue it attempts to resolve bit me!

@Josh-Cena
Copy link
Member

Yeah, good point about different options. I think this fits in #7318.

meta: {
docs: {
description:
'Disallow passing non-Thenable values to promise aggregators',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Docs] Interesting, we don't have a standard for whether we refer to Promises with an upper-case P or lower-case p... Outside the scope of this PR, maybe it'd be a good followup to file an issue about standardizing this in docs?

Not a blocker or request for changes 😄 just ruminating.

Comment on lines 102 to 109
if (
callee.property.type !== AST_NODE_TYPES.Literal ||
typeof callee.property.value !== 'string' ||
!aggregateFunctionNames.includes(callee.property.value)
) {
return;
}
} else if (!aggregateFunctionNames.includes(callee.property.name)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Refactor] This "get the name one way if it's computed or another if it's static" need comes up a lot in rules. We should have a shared utility for it. getStaticStringValue should do the trick, or getStaticValue. One of those 😄.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is already a function getStaticStringValue in eslint-plugin/src/util but it's about finding what the source representation of a value would be, e.g., turning a regex into /this/ or null into the string null, etc.

To avoid confusion with that I've broken out a getMemberName function you can look at and see if it might be worth breaking out into a shared utility

Comment on lines 133 to 131
`
async function test() {
const intersectionArr: (Promise<number> & number)[];
await Promise.all(intersectionArr);
}
`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think unions should be allowed. It's a nice convenience and intentionally part of the Promise aggregator design to allow this.

Copy link
Member

@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking great, thanks for working so much on this rule! I think @auvred reviewing earlier has made my job a lot easier 😄 thanks for that too!

I think I responded to all the pending conversations but I'm not sure. There was a lot to go through 😅. If there's anything I missed, please do start a new comment thread in a file and I'll take a look.

Requesting changes on some more testing and on reusing a shared utility for a static key value. The other points aren't blocking IMO as we can file them as followup issues.

Blue cartoonish character with a gray triangle hat dancing awkwardly on top of a yellow blanket saying "you're doing great!" on top of what looks like a potato with a smiley face.

requiresTypeChecking: true,
},
messages: {
inArray:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Question] Is there a use case for adding an option to allow users to provide an array/tuple where only some of the elements are thenables? Like Promise.all([callAsyncThing(), "other", callAsyncThing()])?

I can't think of one 🤔 so maybe this is a non-actionable comment?

Btw, sorry if I missed this in previous discussion - I looked through and couldn't find anything.

Copy link
Author

@Tjstretchalot Tjstretchalot Jan 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main case I'm specifically looking to lint, and is the most common case where mistakes are made. There is no advantage to doing this from the perspective of control flow (i.e., how long the returned promise takes to fulfill), however, it does change the return value. However, whenever you really do want to provide a non-Thenable to change the control flow, you can always wrap it in Promise.resolve to get exactly the same behavior while making it way more clear how the aggregator will behave. This is still true even when considering if the returned promise is fulfilled synchronously vs asynchronously: these promise aggregators only fulfill synchronously when passed an empty array.

I would argue especially for all of these promise aggregators, the most important thing is making the control flow clear. Any non-Thenable value passed to a promise aggregator will be treated as Promise.resolve(value) according to the spec; see e.g., PerformPromiseAll. Thus given that a value in the list is known to be non-Thenable, there is a static conversion available that will make the control flow easier to understand, or you can be explicit and wrap the value in Promise.resolve, which jumps out at the reader and thus also makes the control flow easier to understand.

Given:

declare const aPromise: Promise<number>;
declare const bPromise: Promise<number;
declare const cNumber: number;
// all

Promise.all([aPromise, bPromise, cNumber])
// equivalent to
Promise.all([aPromise, bPromise, Promise.resolve(cNumber)])
// which resolves in the same amount of time as
Promise.all([aPromise, bPromise])

// previously working, now causes lint error
const [a, b, c] = await Promise.all([aPromise, bPromise, cNumber]);

// suggested change
const [a, b] = await Promise.all([aPromise, bPromise]);
const c = cNumber;
// allSettled

Promise.allSettled([aPromise, bPromise, cNumber]);
// equivalent to
Promise.allSettled([aPromise, bPromise, Promise.resolve(cNumber)]);
// resolves in the same time as
Promise.allSettled([aPromise, bPromise]);

// previously working, now causes lint error
const [aPromise2, bPromise2, cPromise] = await Promise.allSettled([aPromise, bPromise, cNumber]);

// suggested change
const [aPromise2, bPromise2] = await Promise.allSettled([aPromise, bPromise]);
const cPromise = Promise.resolve(cNumber);
// race

await Promise.race([aPromise, bPromise, cNumber]);
// equivalent to
await Promise.race([aPromise, bPromise, Promise.resolve(cNumber)]);
// always resolves immediately (but never synchronously, as Promise.race is never synchronous)

// previously working, now causes lint error
// this is a if a is immediately resolved, b if b is immediately resolved, otherwise c
const aOrBOrC = await Promise.race([aPromise, bPromise, cNumber]);

// suggested change if you really did want the above behavior (most of the time you do NOT)
const aOrBOrC = await Promise.race([aPromise, bPromise, Promise.resolve(cNumber)]);

// suggested change if you actually intended to wait for something to happen
const aOrB = await Promise.race([aPromise, bPromise])

// note that most of the time this comes up, this is whats actually happening:
declare const aPromise: Promise<number>;
declare const bPromise: Promise<number>;
declare const cWrappedPromise: { promise: Promise<number> };

// lint error that catches a real subtle bug
const aOrBOrC = await Promise.race([aPromise, bPromise, cWrappedPromise]);

// correct code
const aOrBOrC = await Promise.race([aPromise, bPromise, cWrappedPromise.promise]);
// any is the same as race for the above purposes, the difference is how rejected values are handled

Copy link
Member

@JoshuaKGoldberg JoshuaKGoldberg Jan 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Non-Actionable] I realized a situation where this option would be helpful! [playground link]

declare function fetch(url: string): Promise<object>;

function example() {
  return Promise.all([
    fetch("/"),
    "in the middle",
    fetch("/")
  ]);
}

If I was the developer here using Promise.all directly to avoid having to make & manage a [object, string, object] tuple, I'd be annoyed at the rule asking me to explicitly Promise.resolve("in the middle"). There's no benefit in this situation beyond consistency - at the cost of simplicity.

But, I get what you're saying that this is really the main point of the rule. I think it's fine for now to leave as-is. Since it's only enabled in the strict config, it should just show up for users who have opted into the more strict opinions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do add an option that allows mixed arrays. I often parallel a bunch of tasks and sometimes tasks shift between being sync and async (for example, because it no longer dynamically imports a library, or it no longer reads a config file). I really don't want to break the symmetry in this case.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, +1ing Josh's request 🙂

@JoshuaKGoldberg JoshuaKGoldberg added the awaiting response Issues waiting for a reply from the OP or another party label Jan 11, 2024
@Tjstretchalot
Copy link
Author

(the force-pushes are just rebasing with main to keep it in sync)

@github-actions github-actions bot removed the awaiting response Issues waiting for a reply from the OP or another party label Jan 12, 2024
Copy link
Member

@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking great, thanks! 🚀

Just requesting changes on the use cases I commented in threads.

@@ -105,7 +117,7 @@ export function isBuiltinTypeAliasLike(
export function isBuiltinSymbolLike(
program: ts.Program,
type: ts.Type,
symbolName: string,
predicate: (symbolName: string) => boolean,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Refactor] Every one of the uses for this function does >=1 === check. How about using a Set instead to make it more clear what's happening?

Suggested change
predicate: (symbolName: string) => boolean,
symbolNames: Set<string>

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way, these functions are already included in several public releases. Shouldn't we keep the already published signatures to avoid breaking changes? Perhaps we can add this new functionality as overload/union? Although these packages are not documented yet, not sure, just asking 😄

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Predicates are more composable than sets and offer easier ergonomics; for sets; (v) => symbolNameSet.has(v) is just as readable as symbolNameSet, and for looking up a single value (symbolName) => symbolName === 'asdf' is in my opinion much easier to interpret than new Set(['foo']) which offers no context for what the set is for, so you end up needing either a comment or breaking it out into a constant. Finally, predicates can be more consistently applied; e.g., we already have predicates for isBuiltinSymbolLikeRecurser, so switching between predicates and sets for arguments requires constantly deciding which ones "tend to be used like sets". Furthermore, predicates portray the intent of the argument more clearly: a set has many functions besides just contains-checking, including iteration. Providing a predicate makes it clear the only part the library function needs is the yes/no answer on each element.

For @auvred 's comment; happy to add the overload/union; adds a little clutter though, so will wait for more discussion since I'm not familiar enough with the project to have any opinion for backwards compatibility vs tidiness for those helpers

Copy link
Member

@JoshuaKGoldberg JoshuaKGoldberg Feb 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backwards compatibility

Yup, this'll need to retain backwards compatibility. I haven't heard of anybody telling us they use it, but a Sourcegraph query for TypeScript files referring to it shows a few public repos that do.

(v) => symbolNameSet.has(v) is just as readable as symbolNameSet

I think this is subjective 😄 I find the latter more intuitive. In my mind I'm kind of implicitly applying the context from "is built-in symbol like" to the last parameter. But that's just me.

offers no context for what the set is for, so you end up needing either a comment or breaking it out into a constant

This is equally applicable to both a predicate and a set. For both, a descriptive parameter name like allowedSymbolNames or isAllowedSymbolName fixes the context issue.

predicates portray the intent of the argument more clearly
a set has many functions besides ...

I suppose Pick<Set<String>, 'has'> would be the most precise thing to take in. All we really want in any of this is to know whether a name is what we're looking for.

One flaw of functions is it leaks the implementation detail of "this gets called up to once per symbol name", so folks can do weird things like:

isBuiltinSymbolLike(program, type, (name) => {
  someRegistry.add(name);
  if (someAttribute(name)) {
    context.report({ data: { name }, messageId: "someMessage", node: someNode });
  }
});

Just pointing this out as a counterpoint to the idea that a predicate adds more practical or semantic safety. I don't think we have to worry about anybody doing such an odd thing.


Anyway, the issue of whether we use a set or predicate is not particularly important to me. It's a small implementation detail and if you feel strongly about it I don't mind just shrugging and seeing what happens.

+1 in general to pushing back on my stylistic suggestions 😄 I like this!

requiresTypeChecking: true,
},
messages: {
inArray:
Copy link
Member

@JoshuaKGoldberg JoshuaKGoldberg Jan 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Non-Actionable] I realized a situation where this option would be helpful! [playground link]

declare function fetch(url: string): Promise<object>;

function example() {
  return Promise.all([
    fetch("/"),
    "in the middle",
    fetch("/")
  ]);
}

If I was the developer here using Promise.all directly to avoid having to make & manage a [object, string, object] tuple, I'd be annoyed at the rule asking me to explicitly Promise.resolve("in the middle"). There's no benefit in this situation beyond consistency - at the cost of simplicity.

But, I get what you're saying that this is really the main point of the rule. I think it's fine for now to leave as-is. Since it's only enabled in the strict config, it should just show up for users who have opted into the more strict opinions.

node:
| TSESTree.MemberExpressionComputedName
| TSESTree.MemberExpressionNonComputedName,
predicate: (name: string) => boolean,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Refactor] Similar to the isBuiltinSymbolLike comment, this is only ever used with a Set<string>. I think expanding the use case to predicates is just a bit more complexity than we need.

Suggested change
predicate: (name: string) => boolean,
memberNames: Set<string>

@JoshuaKGoldberg JoshuaKGoldberg added the awaiting response Issues waiting for a reply from the OP or another party label Jan 24, 2024
@github-actions github-actions bot removed the awaiting response Issues waiting for a reply from the OP or another party label Jan 24, 2024
@Tjstretchalot
Copy link
Author

For some reason I can't respond to some these individually, at least as far as I can tell, only emote to them. Breaking out here instead

#8094 (comment)

Sure, will add default blocking of unknown with an option to unblock. I agree that wouldn't use unknown without a type guard, but the use-case is logically consistent, as it is a type refinement, though one too subtle to be picked up by current typescript (i.e., Awaited<unknown> is just unknown now and for the foreseeable future, but there are some things that can be known about it).

However, I do think this is a bit inconsistent since await-thenable allows unknown (first time trying to use the playground, might have made a mistake, but this is definitely clear in the source code)

#8094 (comment)

Responded in #8094 (comment) - if my argument for predicates isn't convincing will switch it over with an unambigious blocking remark

Copy link

codecov bot commented Feb 2, 2024

Codecov Report

Attention: Patch coverage is 77.77778% with 20 lines in your changes are missing coverage. Please review.

Project coverage is 87.68%. Comparing base (69bd501) to head (ea65212).
Report is 169 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8094      +/-   ##
==========================================
- Coverage   87.72%   87.68%   -0.04%     
==========================================
  Files         397      398       +1     
  Lines       13971    14057      +86     
  Branches     4065     4089      +24     
==========================================
+ Hits        12256    12326      +70     
- Misses       1403     1418      +15     
- Partials      312      313       +1     
Flag Coverage Δ
unittest 87.68% <77.77%> (-0.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files Coverage Δ
packages/type-utils/src/builtinSymbolLikes.ts 0.00% <0.00%> (ø)
...lugin/src/rules/thenable-in-promise-aggregators.ts 86.41% <86.41%> (ø)

@Tjstretchalot
Copy link
Author

Updated to latest main branch and ensured this doesn't change the library functions in a backwards incompatible way (cc: @auvred #8094 (comment)). Initially I did it via overloads so I could mark the string one deprecated, but that fails linting with These overloads can be combined into one signature, so this leaves no deprecation path for the string variant.

As #8301 appears to be resolved with wontfix I'll leave unknown behavior as is.

cc: @JoshuaKGoldberg for updating labels, deciding if predicate->set change is blocking

@Tjstretchalot Tjstretchalot requested a review from auvred February 2, 2024 16:18
@JoshuaKGoldberg JoshuaKGoldberg removed the blocked by another issue Issues which are not ready because another issue needs to be resolved first label Feb 2, 2024
@JoshuaKGoldberg JoshuaKGoldberg self-requested a review February 2, 2024 20:12
@JoshuaKGoldberg
Copy link
Member

Super, thanks! Putting back on the queue to review :)

if (!checker.isArrayType(type) && !checker.isTupleType(type)) {
const indexType = checker.getIndexTypeOfType(type, ts.IndexKind.Number);
if (indexType === undefined) {
return false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Testing] Missing unit test coverage for this and a few other lines. If it's a case where we know the value is definitely not nullish (e.g. an array type having a numeric index) then a ! is reasonable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Non-Actionable] Every time I come back to this PR I get confused as to how such an important-looking file has such major gaps in unit test coverage... it's #6701, but for this package.

Filed #8358 to track adding in unit tests separately.

Copy link
Member

@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a unit test coverage request that's blocking from me right now 🙂 great stuff!

Edit: oh, and the request for an option around mixed arrays.

(I'll re-review more deeply once the comments are all resolved)

@JoshuaKGoldberg JoshuaKGoldberg added the awaiting response Issues waiting for a reply from the OP or another party label Feb 3, 2024
@JoshuaKGoldberg
Copy link
Member

👋 Hey @Tjstretchalot! Just checking in, is this still something you have time for? No worries if not - I just don't want to leave it hanging.

@JoshuaKGoldberg JoshuaKGoldberg added the stale PRs or Issues that are at risk of being or have been closed due to inactivity for a prolonged period label Mar 17, 2024
@Tjstretchalot
Copy link
Author

Hey @JoshuaKGoldberg - apologies for this; work got very busy rather quickly and had to reduce some other stuff to make space. It'll hopefully get better in April, but if someone else wants to pick it up or you want to close until then let me know

@JoshuaKGoldberg
Copy link
Member

Thanks, good to know. I'll remove stale for now but if by the middle of the month it's still pending I'd say we should free it up then. Good luck with the work!

@JoshuaKGoldberg JoshuaKGoldberg removed the stale PRs or Issues that are at risk of being or have been closed due to inactivity for a prolonged period label Mar 26, 2024
@JoshuaKGoldberg
Copy link
Member

Closing as it's been a bit - thanks for getting started on it! If you do end up having time to continue @Tjstretchalot feel free to reopen this PR if it's unlocked, or send a new one otherwise.

In the meantime, if anybody else wants to work on the linked issue, go right ahead. Just be sure to add a co-author attribution if you use any code from this PR. Cheers!

@github-actions github-actions bot locked as resolved and limited conversation to collaborators May 1, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
awaiting response Issues waiting for a reply from the OP or another party
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants