Skip to content

WIP: Address various issues about milestones #143

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

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
33 changes: 33 additions & 0 deletions GitForWindowsHelper/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,39 @@ module.exports = async function (context, req) {
return withStatus(500, undefined, e.message || JSON.stringify(e, null, 2))
}

try {
const {addIssueToCurrentMilestone} = require('./update-milestones')
if (req.headers['x-github-event'] === 'pull_request'
&& ['git-for-windows/build-extra', 'git-for-windows/MINGW-packages', 'git-for-windows/MSYS2-packages'].includes(req.body.repository.full_name)
&& req.body.action === 'closed'
&& req.body.pull_request.merged === 'true') return ok(await addIssueToCurrentMilestone(context, req))
} catch (e) {
context.log(e)
return withStatus(500, undefined, e.message || JSON.stringify(e, null, 2))
}

try {
const {renameCurrentAndCreateNextMilestone} = require('./update-milestones')
if (req.headers['x-github-event'] === 'pull_request'
&& req.body.repository.full_name === 'git-for-windows/git'
&& req.body.action === 'opened'
&& req.body.pull_request.merged === 'true') return ok(await renameCurrentAndCreateNextMilestone(context, req))
} catch (e) {
context.log(e)
return withStatus(500, undefined, e.message || JSON.stringify(e, null, 2))
}

Comment on lines +75 to +85
Copy link
Member

Choose a reason for hiding this comment

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

I'd rather do that when publishing the final release, i.e. at the same time as the current milestone is closed.

Copy link
Member Author

Choose a reason for hiding this comment

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

That would prevent issues with late component updates.

Copy link
Member

Choose a reason for hiding this comment

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

Isn't it the other way round? When a final rebase PR is opened, and /git-artifacts produces an installer that has issues that require a component-update ticket to be addressed, we'd still want that in the same milestone, right?

try {
const {closeReleaseMilestone} = require('./update-milestones')
if (req.headers['x-github-event'] === 'pull_request'
&& req.body.repository.full_name === 'git-for-windows/git'
&& req.body.action === 'closed'
&& req.body.pull_request.merged === 'true') return ok(await closeReleaseMilestone(context, req))
Comment on lines +88 to +91
Copy link
Member

Choose a reason for hiding this comment

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

Alternatively, we could piggy-back onto the code that "pushes" the PR branch to main. That would cut out one extra round trip between GitHub and the GitForWindowsHelper, and simplify the mental model of the process.

Copy link
Member Author

Choose a reason for hiding this comment

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

It does cut out a roundtrip, but it does require duplication of the "is this a pre-release" logic from https://github.com/git-for-windows/git-for-windows-automation/blob/main/.github/actions/github-release/action.yml and maybe some special handling of out of band releases, whereas the release.released approach would get that for free.

Copy link
Member

Choose a reason for hiding this comment

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

Ah, I missed that. But wouldn't we want much more narrow conditions to be met before calling closeReleaseMilestone()?

} catch (e) {
context.log(e)
return withStatus(500, undefined, e.message || JSON.stringify(e, null, 2))
}

try {
const { cascadingRuns, handlePush } = require('./cascading-runs.js')
if (req.headers['x-github-event'] === 'check_run'
Expand Down
50 changes: 50 additions & 0 deletions GitForWindowsHelper/milestones.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const getCurrentMilestone = async (context, token, owner, repo) => {
return await getMilestoneByName(context, token, owner, repo, 'Next release')
}

const getMilestoneByName = async (context, token, owner, repo, name) => {
const githubApiRequest = require('./github-api-request')
const milestones = await githubApiRequest(context, token, 'GET', `/repos/${owner}/${repo}/milestones?state=open`)
if (milestones.length === 2) {
Copy link
Member

Choose a reason for hiding this comment

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

We should probably be more lenient and test > 1 here (and then use .splice(0, milestones.length, filtered) below).

Copy link
Member Author

Choose a reason for hiding this comment

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

I've meant to ask about that.

const filtered = milestones.filter(m => m.title !== name)
if (filtered.length === 1) milestones.splice(0, 2, filtered)
}
if (milestones.length !== 1) throw new Error(`Expected one milestone, got ${milestones.length}`)
return milestones[0]
}

const closeMilestone = async (context, token, owner, repo, milestoneNumber, dueOn) => {
const githubApiRequest = require('./github-api-request')
const payload = {
state: 'closed'
}
if (dueOn) payload.due_on = dueOn
await githubApiRequest(context, token, 'PATCH', `/repos/${owner}/${repo}/milestones/${milestoneNumber}`, payload)
}

const renameMilestone = async (context, token, owner, repo, milestoneNumber, newName) => {
const githubApiRequest = require('./github-api-request')
const payload = {
title: newName
}
await githubApiRequest(context, token, 'PATCH', `/repos/${owner}/${repo}/milestones/${milestoneNumber}`, payload)
}

const openNextReleaseMilestone = async (context, token, owner, repo) => {
const githubApiRequest = require('./github-api-request')
const milestones = await githubApiRequest(context, token, 'GET', `/repos/${owner}/${repo}/milestones?state=open`)
const filtered = milestones.filter(m => m.title === 'Next release')
if (filtered.length === 1) return filtered[0]

return await githubApiRequest(context, token, 'POST', `/repos/${owner}/${repo}/milestones`, {
title: 'Next release'
})
}

module.exports = {
getCurrentMilestone,
getMilestoneByName,
closeMilestone,
renameMilestone,
openNextReleaseMilestone
}
140 changes: 140 additions & 0 deletions GitForWindowsHelper/update-milestones.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
const addIssueToCurrentMilestone= async (context, req) => {
if (req.body.action !== 'closed') return "Nothing to do here: PR has not been closed"
if (req.body.pull_request.merged !== 'true') return "Nothing to do here: PR has been closed, but not by merging"

const owner = 'git-for-windows'
const repo = 'git'
const sender = req.body.sender.login

const getToken = (() => {
let token

const get = async () => {
const getInstallationIdForRepo = require('./get-installation-id-for-repo')
const installationId = await getInstallationIdForRepo(context, owner, repo)
const getInstallationAccessToken = require('./get-installation-access-token')
return await getInstallationAccessToken(context, installationId)
}

return async () => token || (token = await get())
})()

const isAllowed = async (login) => {
if (login === 'gitforwindowshelper[bot]') return true
const getCollaboratorPermissions = require('./get-collaborator-permissions')
const token = await getToken()
const permission = await getCollaboratorPermissions(context, token, owner, repo, login)
return ['ADMIN', 'MAINTAIN', 'WRITE'].includes(permission.toString())
}

if (!await isAllowed(sender)) throw new Error(`${sender} is not allowed to do that`)
Comment on lines +9 to +30
Copy link
Member

Choose a reason for hiding this comment

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

I guess it is about high time to move this (duplicated) logic into a central place instead, and maybe now would be as good a time as any to do that?

Copy link
Member Author

Choose a reason for hiding this comment

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

That thought has crossed my mind as well.


const githubApiRequest = require('./github-api-request')

const candidates = req.body.pull_request.body.match(/(?:[Cc]loses|[Ff]ixes) (?:https:\/\/github\.com\/git-for-windows\/git\/issues\/|#)(\d+)/)

if (candidates.length !== 1) throw new Error(`Expected 1 candidate issue, got ${candidates.length}`)
Comment on lines +34 to +36
Copy link
Member

Choose a reason for hiding this comment

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

I was looking for something more robust, and while there does not seem anything useful in GitHub's REST API, there is something in the GraphQL API. Try this, for example:

gh api graphql -f query='{
  repository(owner: "git-for-windows", name: "MSYS2-packages") {
    pullRequest(number: 236) {
      closingIssuesReferences(first: 10) {
        nodes {
          number
          repository {
            owner {
              login
            }
            name
          }
          title
          url
        }
      }
    }
  }
}'

Over here, this returns this highly useful answer:

{
  "data": {                                                                                                                 "repository": {
      "pullRequest": {
        "closingIssuesReferences": {
          "nodes": [
            {
              "number": 5656,
              "repository": {
                "owner": {
                  "login": "git-for-windows"
                },
                "name": "git"
              },
              "title": "[New curl version] 8.14.1",
              "url": "https://github.com/git-for-windows/git/issues/5656"                                                           }
          ]
        }
      }
    }
  }
}

We already have a precedent of a GraphQL call when looking for the "collaborator permission", so we could something similar here.

Copy link
Member Author

Choose a reason for hiding this comment

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

That does look immensely more helpful than the REST API


const { getCurrentMilestone } = require('./GitForWindowsHelper/milestones')
const current = await getCurrentMilestone(console, await getToken(), owner, repo)

const issueNumber = candidates[0]
const issue = await githubApiRequest(context, await getToken(), 'GET', `/repos/${owner}/${repo}/issues/${issueNumber}`)

if (issue.labels.length>0){
for (const label of issue.labels) {
if (label.name === "component-update"){
await githubApiRequest(context, await getToken(), 'PATCH', `/repos/${owner}/${repo}/issues/${issueNumber}`, {
milestone: current.id
})

return `Added issue ${issueNumber} to milestone "Next release"`
}
}
}

throw new Error(`Issue ${issueNumber} isn't a component update`)
}

const renameCurrentAndCreateNextMilestone = async (context, req) => {
const gitVersionMatch = req.body.pull_request.title.match(/^Rebase to (v\d+\.\d+\.\d+)$/)
if (!gitVersionMatch) throw new Error(`Not a new Git version: ${req.body.pull_request.title}`)
const gitVersion = gitVersionMatch[1]

const owner = 'git-for-windows'
const repo = 'git'
const sender = req.body.sender.login

const getToken = (() => {
let token

const get = async () => {
const getInstallationIdForRepo = require('./get-installation-id-for-repo')
const installationId = await getInstallationIdForRepo(context, owner, repo)
const getInstallationAccessToken = require('./get-installation-access-token')
return await getInstallationAccessToken(context, installationId)
}

return async () => token || (token = await get())
})()

const isAllowed = async (login) => {
if (login === 'gitforwindowshelper[bot]') return true
const getCollaboratorPermissions = require('./get-collaborator-permissions')
const token = await getToken()
const permission = await getCollaboratorPermissions(context, token, owner, repo, login)
return ['ADMIN', 'MAINTAIN', 'WRITE'].includes(permission.toString())
}

if (!await isAllowed(sender)) throw new Error(`${sender} is not allowed to do that`)

const { getCurrentMilestone, renameMilestone, openNextReleaseMilestone } = require('./milestones')
const current = await getCurrentMilestone(console, await getToken(), owner, repo)
await renameMilestone(context, await getToken(), owner, repo,current.id, gitVersion)
await openNextReleaseMilestone(context, await getToken(), owner, repo)
}

const closeReleaseMilestone = async (context, req) => {
const gitVersionMatch = req.body.pull_request.title.match(/^Rebase to (v\d+\.\d+\.\d+)$/)
if (!gitVersionMatch) throw new Error(`Not a new Git version: ${req.body.pull_request.title}`)
const gitVersion = gitVersionMatch[1]

const owner = 'git-for-windows'
const repo = 'git'
const sender = req.body.sender.login

const getToken = (() => {
let token

const get = async () => {
const getInstallationIdForRepo = require('./get-installation-id-for-repo')
const installationId = await getInstallationIdForRepo(context, owner, repo)
const getInstallationAccessToken = require('./get-installation-access-token')
return await getInstallationAccessToken(context, installationId)
}

return async () => token || (token = await get())
})()

const isAllowed = async (login) => {
if (login === 'gitforwindowshelper[bot]') return true
const getCollaboratorPermissions = require('./get-collaborator-permissions')
const token = await getToken()
const permission = await getCollaboratorPermissions(context, token, owner, repo, login)
return ['ADMIN', 'MAINTAIN', 'WRITE'].includes(permission.toString())
}

if (!await isAllowed(sender)) throw new Error(`${sender} is not allowed to do that`)

const { getMilestoneByName, closeMilestone } = require('./milestones')
const current = await getMilestoneByName(console, await getToken(), owner, repo, gitVersion)
if (current.open_issues > 0) throw new Error(`Milestone ${current.title} has ${current.open_issues} open issue(s)!`)
if (current.closed_issues == 0) throw new Error(`Milestone ${current.title} has no closed issue(s)!`)
await closeMilestone(context, await getToken(), owner, repo,current.id)
}

module.exports = {
addIssueToCurrentMilestone,
renameCurrentAndCreateNextMilestone,
closeReleaseMilestone
}