Skip to content
Merged
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
17 changes: 16 additions & 1 deletion docs/conditions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Conditions that match text take a pattern: a regular expression written either b
| `assigneeMatches` | `condition`: pattern | An assignee's login matches. |
| `hasMilestone` | `condition`: boolean | The subject is in a milestone (`true`), or in none (`false`). |
| `milestoneMatches` | `condition`: pattern | The title of the subject's milestone matches. A subject in no milestone never matches. |
| `linksIssue` | `condition`: boolean, optional `keys`: list of Linear team keys | The pull request closes an issue in its description, or carries a Linear key in its branch or title (`true`), or links none (`false`). |
| `reviewerMatches` | `condition`: pattern | A reviewer of the pull request matches: someone asked to review (a login, or a team's slug), or someone who has reviewed. |
| `branchMatches` | `condition`: pattern | The pull request's head branch matches. |
| `baseBranchMatches` | `condition`: pattern | The pull request's base branch, the one it merges into, matches. |
Expand Down Expand Up @@ -125,9 +126,23 @@ exempt:
condition: '^v2'
```

`linksIssue` passes when a pull request is tied to an issue, so a convention can ask every pull request to link its issue. A link is any of:

- A closing reference in the description: GitHub's closing keywords (`close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`, optionally followed by a colon) and then `#12`, `owner/repo#12`, an issue URL or a Linear key, such as `Closes SMC-55`. A bare mention such as `See #12`, or a keyword inside code, does not count. The reference counts whatever branch the pull request targets: it records the author's link, although GitHub closes the issue only on a merge into the default branch.
- A Linear key in the head branch, in any case, since Linear names branches in lower case: `claude/smc-55-links-issue`.
- A Linear key in the title, such as `feat(conditions): linksIssue (SMC-55)`.

A Linear key is a team key, a dash and a number. List your teams in `keys` to count only their keys, matched in any case. Without `keys`, any key counts, in upper case only in the title, so text shaped like a key, such as `utf-8` in a branch, also passes. The description is read from the event, so the condition costs no API call; issues linked only from the pull request's sidebar are not seen. In a stale sweep's `exempt.when`, the listing has no branch, so only the description and title are read.

```yaml
- type: linksIssue
condition: true
keys: [SMC]
```

### Pull request only conditions

`branchMatches`, `baseBranchMatches`, `reviewerMatches`, `isDraft`, `filesMatch`, `changesSize`, `pendingReview`, `requestedChanges`, `isApproved`, `commitMessagesMatch`, `commitsSignedOff`, `hasTrailer`, `hasConflict`, `checksPass` and `checkStatus` only apply to pull requests. On an issue they fail, with the explanation "only applies to pull requests".
`branchMatches`, `baseBranchMatches`, `reviewerMatches`, `isDraft`, `filesMatch`, `changesSize`, `pendingReview`, `requestedChanges`, `isApproved`, `commitMessagesMatch`, `commitsSignedOff`, `hasTrailer`, `hasConflict`, `checksPass`, `checkStatus` and `linksIssue` only apply to pull requests. On an issue they fail, with the explanation "only applies to pull requests".

### Commit conditions

Expand Down
14 changes: 13 additions & 1 deletion docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Type: integer.

### Condition

Type: any of [titleMatches](#titlematches), [descriptionMatches](#descriptionmatches), [creatorMatches](#creatormatches), [branchMatches](#branchmatches), [baseBranchMatches](#basebranchmatches), [authorAssociation](#authorassociation), [hasAssignee](#hasassignee), [assigneeMatches](#assigneematches), [hasMilestone](#hasmilestone), [milestoneMatches](#milestonematches), [isOpen](#isopen), [isLocked](#islocked), [isDraft](#isdraft), [pendingReview](#pendingreview), [requestedChanges](#requestedchanges), [reviewerMatches](#reviewermatches), [commitsSignedOff](#commitssignedoff), [hasConflict](#hasconflict), [checksPass](#checkspass), [checkStatus](#checkstatus), [hasLabel](#haslabel), [isStale](#isstale), [isAbandoned](#isabandoned), [filesMatch](#filesmatch), [changesSize](#changessize), [isApproved](#isapproved), [commitMessagesMatch](#commitmessagesmatch), [hasTrailer](#hastrailer), [$and](#and), [$or](#or), [$not](#not), [$only](#only).
Type: any of [titleMatches](#titlematches), [descriptionMatches](#descriptionmatches), [creatorMatches](#creatormatches), [branchMatches](#branchmatches), [baseBranchMatches](#basebranchmatches), [authorAssociation](#authorassociation), [hasAssignee](#hasassignee), [assigneeMatches](#assigneematches), [hasMilestone](#hasmilestone), [milestoneMatches](#milestonematches), [linksIssue](#linksissue), [isOpen](#isopen), [isLocked](#islocked), [isDraft](#isdraft), [pendingReview](#pendingreview), [requestedChanges](#requestedchanges), [reviewerMatches](#reviewermatches), [commitsSignedOff](#commitssignedoff), [hasConflict](#hasconflict), [checksPass](#checkspass), [checkStatus](#checkstatus), [hasLabel](#haslabel), [isStale](#isstale), [isAbandoned](#isabandoned), [filesMatch](#filesmatch), [changesSize](#changessize), [isApproved](#isapproved), [commitMessagesMatch](#commitmessagesmatch), [hasTrailer](#hastrailer), [$and](#and), [$or](#or), [$not](#not), [$only](#only).

### Pattern

Expand Down Expand Up @@ -479,6 +479,18 @@ The title of the subject's milestone matches a pattern.

Other keys are an error.

### linksIssue

The pull request closes an issue in its description, or carries a Linear key in its branch or title; links none when false.

| Key | Type | Required | Notes |
| --- | --- | --- | --- |
| `type` | `linksIssue` | yes | |
| `condition` | boolean | yes | |
| `keys` | list of string | no | The Linear team keys that count; any key when omitted. At least 1 item(s). |

Other keys are an error.

### isOpen

The subject is open, or closed when false.
Expand Down
47 changes: 47 additions & 0 deletions packages/conditions/src/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,46 @@ const reviewersOf = (requested: ReadonlyArray<string>, reviews: ReadonlyArray<Re

const logins = (names: ReadonlyArray<string>) => names.map((name) => `@${name}`).join(', ')

// GitHub's closing keywords, followed by an issue reference or a Linear key:
// `#12`, `owner/repo#12`, an issue URL or `SMC-55`.
const CLOSING =
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+(#\d+|[\w.-]+\/[\w.-]+#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+|[A-Za-z][A-Za-z0-9]*-\d+)(?![\w-])/gi

// A Linear key: one of the listed team keys in any case, or without a list,
// any key (in upper case only when `upper` is set), then a dash and a number.
const linearKey = (keys: ReadonlyArray<string> | undefined, upper: boolean) =>
new RegExp(
`(?<![A-Za-z0-9])(?:${keys === undefined ? '[A-Z][A-Z0-9]*' : keys.join('|')})-\\d+(?![A-Za-z0-9])`,
keys === undefined && upper ? '' : 'i',
)

// The first issue a pull request links, described for the report: a closing
// reference in the description, then a Linear key in the branch or title.
const issueLink = (subject: Subject, keys: ReadonlyArray<string> | undefined): string | undefined => {
const key = linearKey(keys, false)
// GitHub reads no closing keyword inside code: fenced blocks, followed line
// by line, and inline spans. An unclosed fence runs to the end.
let fence: string | undefined
const prose = subject.body
.split('\n')
.map((line) => {
const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1]
if (fence === undefined && marker === undefined) return line.replace(/`[^`\n]*`/g, ' ')
if (fence === undefined) fence = marker
else if (marker !== undefined && marker[0] === fence[0] && marker.length >= fence.length) fence = undefined
return ''
})
.join('\n')
const closes = [...prose.matchAll(CLOSING)]
.map((match) => match[1] ?? '')
.find((ref) => ref.includes('#') || ref.includes('/') || key.test(ref))
if (closes !== undefined) return `closes ${closes}`
const branch = key.exec(subject.headBranch ?? '')?.[0]
if (branch !== undefined) return `Linear key ${branch} in the branch`
const title = linearKey(keys, true).exec(subject.title)?.[0]
return title === undefined ? undefined : `Linear key ${title} in the title`
}

const PULL_REQUEST_ONLY = new Set([
'branchMatches',
'baseBranchMatches',
Expand All @@ -175,6 +215,7 @@ const PULL_REQUEST_ONLY = new Set([
'checksPass',
'checkStatus',
'reviewerMatches',
'linksIssue',
])

const evaluateCondition = (condition: Condition, subject: Subject): Effect.Effect<ConditionResult, MissingFacet> => {
Comment thread
TGTGamer marked this conversation as resolved.
Resolved
Comment thread
TGTGamer marked this conversation as resolved.
Resolved
Comment thread
TGTGamer marked this conversation as resolved.
Resolved
Comment thread
TGTGamer marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -271,6 +312,12 @@ const evaluateCondition = (condition: Condition, subject: Subject): Effect.Effec
),
)
}
case 'linksIssue': {
const link = issueLink(subject, condition.keys)
return Effect.succeed(
result(condition.type, (link !== undefined) === condition.condition, link ?? 'links no issue'),
)
}
case 'isOpen':
return Effect.succeed(
result(condition.type, subject.open === condition.condition, subject.open ? 'open' : 'closed'),
Expand Down
1 change: 1 addition & 0 deletions packages/conditions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export {
IsLocked,
IsOpen,
IsStale,
LinksIssue,
MilestoneMatches,
PendingReview,
RequestedChanges,
Expand Down
42 changes: 42 additions & 0 deletions packages/conditions/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,47 @@ export const HasMilestone = flag('hasMilestone', 'The subject is in a milestone,
*/
export const MilestoneMatches = matches('milestoneMatches', "The title of the subject's milestone matches a pattern.")

/**
* The pull request links an issue (true), or links none (false): its
* description closes an issue, or its head branch or title carries a Linear
* issue key.
*
* @remarks
* A description closes an issue with one of GitHub's closing keywords
* (`close`, `fix` or `resolve`, in any tense) followed by `#12`,
* `owner/repo#12`, an issue URL or a Linear key such as `SMC-55`. A Linear key
* is a team key, a dash and a number, matched in any case in the branch and
* description, since Linear writes branch names in lower case. `keys` limits
* Linear keys to the listed teams. Without it any key counts, in upper case
* only in the title, so text such as `utf-8` in a branch also passes: list the
* keys to rule that out.
*
* @example
* ```ts import.meta.vitest name="LinksIssue"
* import { LinksIssue } from '@resnovas/conditions'
* import { Schema } from 'effect'
*
* Schema.is(LinksIssue)({ type: 'linksIssue', condition: true, keys: ['SMC'] }) // => true
* Schema.is(LinksIssue)({ type: 'linksIssue', condition: true, keys: ['SMC-1'] }) // => false
* ```
*/
export const LinksIssue = Schema.Struct({
type: Schema.Literal('linksIssue'),
condition: Schema.Boolean,
keys: Schema.optionalWith(
Schema.NonEmptyArray(
Schema.String.pipe(Schema.pattern(/^[A-Za-z][A-Za-z0-9]*$/)).annotations({
description: 'A Linear team key, such as SMC.',
}),
).annotations({ description: 'The Linear team keys that count; any key when omitted.' }),
{ exact: true },
),
}).annotations({
identifier: 'linksIssue',
description:
'The pull request closes an issue in its description, or carries a Linear key in its branch or title; links none when false.',
})

/**
* The subject is open (true) or closed (false).
*
Expand Down Expand Up @@ -545,6 +586,7 @@ const Leaf = Schema.Union(
AssigneeMatches,
HasMilestone,
MilestoneMatches,
LinksIssue,
IsOpen,
IsLocked,
IsDraft,
Expand Down
33 changes: 33 additions & 0 deletions schema/smartcloud.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@
{
"$ref": "#/$defs/milestoneMatches"
},
{
"$ref": "#/$defs/linksIssue"
},
{
"$ref": "#/$defs/isOpen"
},
Expand Down Expand Up @@ -523,6 +526,36 @@
"additionalProperties": false,
"description": "The title of the subject's milestone matches a pattern."
},
"linksIssue": {
"type": "object",
"required": [
"type",
"condition"
],
"properties": {
"type": {
"type": "string",
"enum": [
"linksIssue"
]
},
"condition": {
"type": "boolean"
},
"keys": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"description": "A Linear team key, such as SMC.",
"pattern": "^[A-Za-z][A-Za-z0-9]*$"
},
"description": "The Linear team keys that count; any key when omitted."
}
},
"additionalProperties": false,
"description": "The pull request closes an issue in its description, or carries a Linear key in its branch or title; links none when false."
},
"isOpen": {
"type": "object",
"required": [
Expand Down
97 changes: 97 additions & 0 deletions tests/conditions/src/evaluate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,84 @@ const cases: ReadonlyArray<readonly [string, Condition, Subject, boolean]> = [
['milestoneMatches', { type: 'milestoneMatches', condition: '^v2' }, pullRequest({ milestone: 'v2.0' }), true],
['milestoneMatches, no match', { type: 'milestoneMatches', condition: '^v3' }, issue({ milestone: 'v2.0' }), false],
['milestoneMatches, in no milestone', { type: 'milestoneMatches', condition: '.*' }, issue(), false],
['linksIssue, none', { type: 'linksIssue', condition: true }, pullRequest(), false],
['linksIssue false, none', { type: 'linksIssue', condition: false }, pullRequest(), true],
['linksIssue, closes #12', { type: 'linksIssue', condition: true }, pullRequest({ body: 'Closes #12' }), true],
['linksIssue false, closes #12', { type: 'linksIssue', condition: false }, pullRequest({ body: 'fixes #12' }), false],
[
'linksIssue, a closing keyword with a colon and another repository',
{ type: 'linksIssue', condition: true, keys: ['SMC'] },
pullRequest({ body: 'Resolved: Resnovas/smartcloud#12' }),
true,
],
[
'linksIssue, a closing issue URL',
{ type: 'linksIssue', condition: true },
pullRequest({ body: 'Fixed https://github.com/Resnovas/smartcloud/issues/12.' }),
true,
],
[
'linksIssue, a mention without a closing keyword',
{ type: 'linksIssue', condition: true },
pullRequest({ body: 'See #12, prefix #13' }),
false,
],
[
'linksIssue, a closing keyword in code',
{ type: 'linksIssue', condition: true },
pullRequest({ body: 'Write `Closes #12`, or:\n\n```md\nFixes #13\n```\n\n~~~\nResolves #14' }),
false,
],
[
'linksIssue, a closing keyword after code',
{ type: 'linksIssue', condition: true },
pullRequest({ body: '```\nx\n```\nCloses #12' }),
true,
],
[
'linksIssue, closes a Linear key',
{ type: 'linksIssue', condition: true },
pullRequest({ body: 'Closes SMC-55' }),
true,
],
[
'linksIssue, closes a Linear key of another team',
{ type: 'linksIssue', condition: true, keys: ['SMC'] },
pullRequest({ body: 'Closes ENG-55' }),
false,
],
[
'linksIssue, a Linear key in a lower-case branch',
{ type: 'linksIssue', condition: true, keys: ['SMC'] },
pullRequest({ headBranch: 'claude/smc-55-links-issue' }),
true,
],
[
'linksIssue, a listed key must stand alone',
{ type: 'linksIssue', condition: true, keys: ['SMC'] },
pullRequest({ headBranch: 'feat/xsmc-55', title: 'feat: SMC-55a' }),
false,
],
[
'linksIssue, a Linear key in the title',
{ type: 'linksIssue', condition: true },
pullRequest({ title: 'feat(conditions): linksIssue (SMC-55)' }),
true,
],
[
'linksIssue, a lower-case key in the title needs a listed key',
{ type: 'linksIssue', condition: true },
pullRequest({ title: 'fix: decode utf-8' }),
false,
],
[
'linksIssue, a listed key in the title in any case',
{ type: 'linksIssue', condition: true, keys: ['ENG', 'SMC'] },
pullRequest({ title: 'fix: smc-55' }),
true,
],
['linksIssue, missing branch', { type: 'linksIssue', condition: true }, withoutBranch, false],
['linksIssue, on an issue', { type: 'linksIssue', condition: true }, issue({ body: 'Closes #12' }), false],
['isOpen', { type: 'isOpen', condition: true }, pullRequest(), true],
['isOpen false on a closed item', { type: 'isOpen', condition: false }, pullRequest({ open: false }), true],
['isLocked', { type: 'isLocked', condition: true }, issue({ locked: true }), true],
Expand Down Expand Up @@ -520,6 +598,24 @@ describe('evaluate: milestones', () => {
)
})

describe('evaluate: linked issues', () => {
const detail = (condition: Condition, subject: Subject) =>
Effect.map(evaluate({ condition: [condition] }, subject), (evaluation) => evaluation.results[0]?.detail)

it.effect('explains the first link, from the description, then the branch, then the title', () =>
Effect.gen(function* () {
const linked = { type: 'linksIssue', condition: true } as const
const everywhere = pullRequest({ body: 'Closes #12', headBranch: 'smc-55-x', title: 'feat: SMC-56' })
expect(yield* detail(linked, everywhere)).toBe('closes #12')
expect(yield* detail(linked, pullRequest({ headBranch: 'smc-55-x', title: 'feat: SMC-56' }))).toBe(
'Linear key smc-55 in the branch',
)
expect(yield* detail(linked, pullRequest({ title: 'feat: SMC-56' }))).toBe('Linear key SMC-56 in the title')
expect(yield* detail(linked, pullRequest())).toBe('links no issue')
}),
)
})

describe('evaluate: facets', () => {
it.effect('a facet that was not loaded fails with MissingFacet', () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -764,5 +860,6 @@ describe('requiredFacets', () => {
it('needs nothing for conditions on the event payload alone', () => {
expect(requiredFacets([{ condition: [{ type: 'isOpen', condition: true }] }]).size).toBe(0)
expect(requiredFacets([{ condition: [{ type: 'milestoneMatches', condition: 'x' }] }]).size).toBe(0)
expect(requiredFacets([{ condition: [{ type: 'linksIssue', condition: true }] }]).size).toBe(0)
})
})
4 changes: 4 additions & 0 deletions tests/conditions/src/schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ describe('ConditionGroup', () => {
{ type: 'assigneeMatches', condition: 'x' },
{ type: 'hasMilestone', condition: true },
{ type: 'milestoneMatches', condition: 'x' },
{ type: 'linksIssue', condition: true },
{ type: 'linksIssue', condition: false, keys: ['SMC', 'eng2'] },
{ type: 'isOpen', condition: true },
{ type: 'isLocked', condition: true },
{ type: 'isDraft', condition: true },
Expand Down Expand Up @@ -81,6 +83,8 @@ describe('ConditionGroup', () => {
expect(Either.isLeft(decode({ condition: [{ type: 'isFriday', condition: true }] }))).toBe(true)
expect(Either.isLeft(decode({ condition: [{ type: 'isStale', condition: -1 }] }))).toBe(true)
expect(Either.isLeft(decode({ condition: [{ type: 'titleMatches', condition: '[' }] }))).toBe(true)
expect(Either.isLeft(decode({ condition: [{ type: 'linksIssue', condition: true, keys: [] }] }))).toBe(true)
expect(Either.isLeft(decode({ condition: [{ type: 'linksIssue', condition: true, keys: ['SMC-'] }] }))).toBe(true)
})

it('round-trips: encoding then decoding returns the original', () => {
Expand Down
Loading