Skip to content

Commit cd1a6a8

Browse files
authored
feat!: parse trailers using git if available, allow longer lines (#144)
1 parent 58c48dc commit cd1a6a8

17 files changed

Lines changed: 662 additions & 122 deletions

lib/gitlint-parser.js

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { spawnSync } from 'node:child_process'
2+
import Base from 'gitlint-parser-base'
3+
4+
const revertRE = /Revert "(.*)"$/
5+
const workingRE = /Working on v([\d]+)\.([\d]+).([\d]+)$/
6+
const releaseRE = /([\d]{4})-([\d]{2})-([\d]{2}),? Version/
7+
const reviewedByRE = /^Reviewed-By: (.*)$/
8+
const fixesRE = /^Fixes: (.*)$/
9+
const prUrlRE = /^PR-URL: (.*)$/
10+
const refsRE = /^Refs?: (.*)$/
11+
12+
export default class Parser extends Base {
13+
constructor (str, validator) {
14+
super(str, validator)
15+
this.subsystems = []
16+
this.fixes = []
17+
this.prUrl = null
18+
this.refs = []
19+
this.reviewers = []
20+
this._metaStart = 0
21+
this._metaEnd = 0
22+
this._parse()
23+
}
24+
25+
_setMetaStart (n) {
26+
if (this._metaStart) return
27+
this._metaStart = n
28+
}
29+
30+
_setMetaEnd (n) {
31+
if (n < this._metaEnd) return
32+
this._metaEnd = n
33+
}
34+
35+
_parseTrailers (body) {
36+
const interpretTrailers = commitMessage => spawnSync('git', [
37+
'interpret-trailers', '--only-trailers', '--only-input', '--no-divider'
38+
], {
39+
encoding: 'utf-8',
40+
input: `'dummy subject\n\n${commitMessage.join('\n')}\n`
41+
}).stdout
42+
43+
let originalTrailers
44+
try {
45+
originalTrailers = interpretTrailers(body).trim()
46+
} catch (err) {
47+
console.warn('git is not available, trailers detection might be a bit ' +
48+
'off which is acceptable in most cases', err)
49+
return body
50+
}
51+
const trailerFreeBody = body.slice(1) // clone, and remove the first empty line
52+
const stillInTrailers = () => {
53+
const result = interpretTrailers(trailerFreeBody)
54+
return result.length && originalTrailers.startsWith(result.trim())
55+
}
56+
for (let i = trailerFreeBody.length - 1; stillInTrailers(); i--) {
57+
// Remove last line until git no longer detects any trailers
58+
trailerFreeBody.pop()
59+
}
60+
this._metaStart = trailerFreeBody.length + 1 // the subject line needs to be counted
61+
for (let i = trailerFreeBody.length - 1; trailerFreeBody[i] === ''; i--) {
62+
// Remove additional empty line(s)
63+
trailerFreeBody.pop()
64+
}
65+
this._metaEnd = body.length - 1
66+
this.trailerFreeBody = trailerFreeBody
67+
return (this.trailers = originalTrailers.split('\n'))
68+
}
69+
70+
_parse () {
71+
const revert = this.isRevert()
72+
if (!revert) {
73+
this.subsystems = getSubsystems(this.title || '')
74+
} else {
75+
const matches = this.title.match(revertRE)
76+
if (matches) {
77+
const title = matches[1]
78+
this.subsystems = getSubsystems(title)
79+
}
80+
}
81+
82+
const trailers = this._parseTrailers(this.body)
83+
84+
for (let i = 0; i < trailers.length; i++) {
85+
const line = trailers[i]
86+
const reviewedBy = reviewedByRE.exec(line)
87+
if (reviewedBy) {
88+
this._setMetaStart(i)
89+
this._setMetaEnd(i)
90+
this.reviewers.push(reviewedBy[1])
91+
continue
92+
}
93+
94+
const fixes = fixesRE.exec(line)
95+
if (fixes) {
96+
this._setMetaStart(i)
97+
this._setMetaEnd(i)
98+
this.fixes.push(fixes[1])
99+
continue
100+
}
101+
102+
const prUrl = prUrlRE.exec(line)
103+
if (prUrl) {
104+
this._setMetaStart(i)
105+
this._setMetaEnd(i)
106+
this.prUrl = prUrl[1]
107+
continue
108+
}
109+
110+
const refs = refsRE.exec(line)
111+
if (refs) {
112+
this._setMetaStart(i)
113+
this._setMetaEnd(i)
114+
this.refs.push(refs[1])
115+
continue
116+
}
117+
118+
if (this._metaStart && !this._metaEnd) { this._setMetaEnd(i) }
119+
}
120+
}
121+
122+
isRevert () {
123+
return revertRE.test(this.title)
124+
}
125+
126+
isWorkingCommit () {
127+
return workingRE.test(this.title)
128+
}
129+
130+
isReleaseCommit () {
131+
return releaseRE.test(this.title)
132+
}
133+
134+
toJSON () {
135+
return {
136+
sha: this.sha,
137+
title: this.title,
138+
subsystems: this.subsystems,
139+
author: this.author,
140+
date: this.date,
141+
fixes: this.fixes,
142+
refs: this.refs,
143+
prUrl: this.prUrl,
144+
reviewers: this.reviewers,
145+
body: this.body,
146+
trailers: this.trailers,
147+
trailerFreeBody: this.trailerFreeBody,
148+
revert: this.isRevert(),
149+
release: this.isReleaseCommit(),
150+
working: this.isWorkingCommit(),
151+
metadata: {
152+
start: this._metaStart,
153+
end: this._metaEnd
154+
}
155+
}
156+
}
157+
}
158+
159+
function getSubsystems (str) {
160+
str = str || ''
161+
const colon = str.indexOf(':')
162+
if (colon === -1) {
163+
return []
164+
}
165+
166+
const subStr = str.slice(0, colon)
167+
const subs = subStr.split(',')
168+
return subs.map((item) => {
169+
return item.trim()
170+
})
171+
}

lib/rules/line-length.js

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ export default {
77
recommended: true
88
},
99
defaults: {
10-
length: 72
10+
length: 72,
11+
trailerLength: 120
1112
},
1213
options: {
13-
length: 72
14+
length: 72,
15+
trailerLength: 120
1416
},
1517
validate: (context, rule) => {
1618
const len = rule.options.length
@@ -27,19 +29,14 @@ export default {
2729
return
2830
}
2931
let failed = false
30-
for (let i = 0; i < parsed.body.length; i++) {
31-
const line = parsed.body[i]
32+
const body = parsed.trailerFreeBody ?? parsed.body
33+
for (let i = 0; i < body.length; i++) {
34+
const line = body[i]
3235

3336
// Skip quoted lines, e.g. for original commit messages of V8 backports.
3437
if (line.startsWith(' ')) { continue }
3538
// Skip lines with URLs.
3639
if (/https?:\/\//.test(line)) { continue }
37-
// Skip co-authorship.
38-
if (/^co-authored-by:/i.test(line)) { continue }
39-
// Skip DCO sign-offs.
40-
if (/^signed-off-by:/i.test(line)) { continue }
41-
// Skip agentic assistants.
42-
if (/^assisted-by:/i.test(line)) { continue }
4340

4441
if (line.length > len) {
4542
failed = true
@@ -48,7 +45,23 @@ export default {
4845
message: `Line should be <= ${len} columns.`,
4946
string: line,
5047
maxLength: len,
51-
line: i,
48+
line: i + parsed.body.length - body.length,
49+
column: len,
50+
level: 'fail'
51+
})
52+
}
53+
}
54+
for (let i = 0; i < (parsed.trailers?.length ?? 0); i++) {
55+
const line = parsed.trailers[i]
56+
const len = rule.options.trailerLength
57+
if (line.length > len) {
58+
failed = true
59+
context.report({
60+
id,
61+
message: `Trailer should be <= ${len} columns.`,
62+
string: line,
63+
maxLength: len,
64+
line: i + parsed.body.length - parsed.trailers.length,
5265
column: len,
5366
level: 'fail'
5467
})

lib/rules/signed-off-by.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,12 @@ export default {
6767
return
6868
}
6969

70+
const body = parsed.trailers ?? parsed.body
71+
7072
// Backport commits (identified by a Backport-PR-URL trailer) are
7173
// cherry-picks of existing commits into release branches. The
7274
// original commit was already validated.
73-
if (parsed.body.some((line) => backportPattern.test(line))) {
75+
if (body.some((line) => backportPattern.test(line))) {
7476
context.report({
7577
id,
7678
message: 'skipping sign-off for backport commit',
@@ -80,9 +82,9 @@ export default {
8082
return
8183
}
8284

83-
const signoffs = parsed.body
84-
.map((line, i) => [line, i])
85-
.filter(([line]) => signoffPattern.test(line))
85+
const signoffs = body
86+
.filter(line => signoffPattern.test(line))
87+
.map((line, i) => [line, i + parsed.body.length - body.length])
8688

8789
// Bot-authored commits don't need a sign-off.
8890
// If they have one, warn; otherwise pass.

lib/validator.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import EE from 'node:events'
2-
import Parser from 'gitlint-parser-node'
2+
import Parser from './gitlint-parser.js'
33
import BaseRule from './rule.js'
44

55
// Rules

0 commit comments

Comments
 (0)